Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617//! Focus: one thread at a time, as a large card in the middle of the//! window, and a row of big buttons for what to do with it. `l` opens a//! row of label chips under the card, `g` a row of labels to go to, and//! `o` an overview of the whole queue to jump around in.
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(.focus);
const Focus = @This();
pub const prototype: Prototype = .{ .name = "focus", .title = "Focus", .idea = "One thread at a time as a big card, with big buttons that each show their key.", .build = build, .shots = &.{ .{ .name = "skipped", .keys = &kit.keys("jj") }, .{ .name = "labeling", .keys = &kit.keys("l") }, .{ .name = "labeled", .keys = &kit.keys("av") }, .{ .name = "overview", .keys = &([_]Key{.named(.escape)} ++ kit.keys("ojjj")) }, .{ .name = "jumped", .keys = &.{.named(.@"return")} }, .{ .name = "goto", .keys = &kit.keys("g") }, .{ .name = "someday", .keys = &kit.keys("y") }, .{ .name = "inbox-zero", .keys = &kit.keys("gieeeeeeeeeeeeeeee") }, },};
/// What keys mean right now: the card alone, or one of the rows or/// panels that sit over or under it.const Mode = enum { card, label, goto, overview };
/// The overview's rows are this tall, so the cursor can be kept in view.const row_height = 44;const card_width = 760;
/// What a click does: press a key, or jump to a row of the overview.const Action = union(enum) { key: Key, jump: u32,};
arena: Allocator,window: siv.Window,store: *Store,mode: Mode,label: Label,/// The queue, newest first.ids: []Store.Id,ids_buf: [Store.capacity]Store.Id,/// The thread on the card, an index into `ids`; 0 when the queue is empty.current: u32,/// The overview's highlighted row.cursor: u32,/// How far the overview is scrolled, in pixels.overview_top: f32,
header: siv.Node,center: siv.Node,card_slot: siv.Node,card: siv.Node,picker_slot: siv.Node,picker: siv.Node,empty: siv.Node,empty_title: siv.Node,overview: siv.Node,overview_title: siv.Node,overview_scroll: siv.Node,overview_rows: 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(Focus); self.* = .{ .arena = arena, .window = window, .store = store, .mode = .card, .label = .inbox, .ids = &.{}, .ids_buf = undefined, .current = 0, .cursor = 0, .overview_top = 0, .header = .null, .center = .null, .card_slot = .null, .card = .null, .picker_slot = .null, .picker = .null, .empty = .null, .empty_title = .null, .overview = .null, .overview_title = .null, .overview_scroll = .null, .overview_rows = .null, .bar_slot = .null, .bar = .null, }; self.ids = store.query(.{ .label = self.label }, &self.ids_buf); self.markRead();
const txn = try siv.txnBegin(window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(window); siv.setVisual(txn, root, .{ .background_token = .window_bg }); const column = siv.flex(txn, root, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, column, .{ .width = kit.whole, .height = kit.whole });
self.header = kit.text(txn, column, "", .{ .color_token = .muted_fg, .@"align" = .center }); siv.setLayout(txn, self.header, .{ .padding = .{ .top = 18, .bottom = 6 } }); siv.setSemantics(txn, self.header, .{ .role = .heading, .level = 1 });
const body = siv.stack(txn, column, .{}); siv.setLayout(txn, body, .{ .grow = 1, .basis = kit.px(0) }); self.center = siv.flex(txn, body, .{ .axis = .column, .gap = 18, .align_items = .center, .justify = .center }); siv.setLayout(txn, self.center, .{ .width = kit.whole, .height = kit.whole, .padding = kit.edges(16) }); self.card_slot = siv.box(txn, self.center); self.picker_slot = siv.box(txn, self.center); self.buildEmpty(txn, body); self.buildOverview(txn, body);
self.bar_slot = siv.flex(txn, column, .{ .axis = .column, .align_items = .center }); siv.setLayout(txn, self.bar_slot, .{ .padding = .{ .top = 8, .bottom = 22 } }); try self.render(txn); kit.keymap(Focus, txn, root, self); try siv.txnCommit(txn);}
fn buildEmpty(self: *Focus, txn: siv.Txn, body: siv.Node) void { self.empty = siv.flex(txn, body, .{ .axis = .column, .gap = 10, .align_items = .center, .justify = .center }); siv.setLayout(txn, self.empty, .{ .width = kit.whole, .height = kit.whole }); _ = kit.text(txn, self.empty, "✓", .{ .font_size = 56, .color_token = .success }); const title: siv.TextStyleDesc.Options = .{ .size_role = .title, .weight = .bold, .color_token = .window_fg }; self.empty_title = kit.text(txn, self.empty, "", title); siv.setSemantics(txn, self.empty_title, .{ .role = .heading, .level = 2 }); const hint = siv.flex(txn, self.empty, .{ .gap = 6, .align_items = .center }); _ = kit.text(txn, hint, "Nothing left to read here. Press", .{ .color_token = .muted_fg }); _ = kit.keycap(txn, hint, "g"); _ = kit.text(txn, hint, "to go to another label.", .{ .color_token = .muted_fg });}
fn buildOverview(self: *Focus, txn: siv.Txn, body: siv.Node) void { self.overview = siv.flex(txn, body, .{ .axis = .column, .gap = 8, .align_items = .center }); siv.setLayout(txn, self.overview, .{ .width = kit.whole, .height = kit.whole, .padding = kit.edges(16) }); siv.setVisual(txn, self.overview, .{ .background_token = .window_bg }); const panel = siv.flex(txn, self.overview, .{ .axis = .column, .gap = 8, .align_items = .stretch }); siv.setLayout(txn, panel, .{ .width = kit.px(card_width + 120), .grow = 1, .basis = kit.px(0) }); const title: siv.TextStyleDesc.Options = .{ .size_role = .heading, .weight = .bold, .color_token = .window_fg }; self.overview_title = kit.text(txn, panel, "", title); siv.setSemantics(txn, self.overview_title, .{ .role = .heading, .level = 2 }); self.overview_scroll = siv.scroll(txn, panel, .vertical); siv.setLayout(txn, self.overview_scroll, .{ .grow = 1, .basis = kit.px(0) }); siv.setVisual(txn, self.overview_scroll, .{ .background_token = .view_bg, .border_token = .border, .border_width = 1, .radius = kit.corners(12), .flags = .{ .clip = true }, }); siv.setHidden(txn, self.overview, true);}
pub fn onKey(self: *Focus, key: Key) !bool { return switch (self.mode) { .card => self.cardKey(key), .label => self.labelKey(key), .goto => self.gotoKey(key), .overview => self.overviewKey(key), };}
fn cardKey(self: *Focus, key: Key) !bool { if (key.is('g')) return self.setMode(.goto); if (self.ids.len == 0) return false; if (key.is('j') or key.isNamed(.down) or key.isNamed(.right)) return self.go(@as(i32, @intCast(self.current)) + 1); if (key.is('k') or key.isNamed(.up) or key.isNamed(.left)) return self.go(@as(i32, @intCast(self.current)) - 1); if (key.is('e')) return self.archive(); if (key.is('s')) return self.toggle(.starred); if (key.is('u')) return self.toggleUnread(); if (key.is('l')) return self.setMode(.label); if (key.is('o')) { self.cursor = self.current; return self.setMode(.overview); } return false;}
fn labelKey(self: *Focus, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return") or key.is('l')) { // Toggling may have taken the thread out of this queue; the row // kept it on the card until now so the chips stayed put. self.mode = .card; try self.requery(self.ids[self.current]); return true; } const c = key.printable() orelse return true; const label = Label.fromKey(c) orelse return true; if (label.info().kind != .user) return true; self.store.toggleLabel(self.ids[self.current], label); try self.refresh(); return true;}
fn gotoKey(self: *Focus, key: Key) !bool { if (key.isNamed(.escape) or key.is('g')) return self.setMode(.card); const c = key.printable() orelse return true; self.label = Label.fromKey(c) orelse return true; self.mode = .card; self.current = 0; try self.requery(null); self.markRead(); try self.refresh(); return true;}
fn overviewKey(self: *Focus, key: Key) !bool { if (key.isNamed(.escape) or key.is('o')) return self.setMode(.card); if (key.isNamed(.@"return")) return self.jump(self.cursor); const last: i32 = @intCast(self.ids.len -| 1); const delta: i32 = if (key.is('j') or key.isNamed(.down)) 1 else if (key.is('k') or key.isNamed(.up)) -1 else 0; if (delta == 0) return true; self.cursor = @intCast(math.clamp(@as(i32, @intCast(self.cursor)) + delta, 0, last)); try self.refresh(); return true;}
fn setMode(self: *Focus, mode: Mode) !bool { self.mode = mode; try self.refresh(); return true;}
/// Shows the thread at `index` in the queue, clamped to it.fn go(self: *Focus, index: i32) !bool { const last: i32 = @intCast(self.ids.len - 1); self.current = @intCast(math.clamp(index, 0, last)); self.markRead(); try self.refresh(); return true;}
fn jump(self: *Focus, index: u32) !bool { self.mode = .card; return self.go(@intCast(index));}
/// Archives the thread and moves on: the next one takes its place, or in/// a label other than Inbox, where the thread stays, the card advances.fn archive(self: *Focus) !bool { const id = self.ids[self.current]; self.store.archive(id); try self.requery(null); if (self.current < self.ids.len and self.ids[self.current] == id and self.current + 1 < self.ids.len) { self.current += 1; } self.markRead(); try self.refresh(); return true;}
fn toggle(self: *Focus, label: Label) !bool { const id = self.ids[self.current]; self.store.toggleLabel(id, label); try self.requery(id); try self.refresh(); return true;}
fn toggleUnread(self: *Focus) !bool { const id = self.ids[self.current]; self.store.setUnread(id, !self.store.get(id).unread); try self.refresh(); return true;}
/// Reading a card marks it read, as opening a thread does; `u` undoes it.fn markRead(self: *Focus) void { if (self.current >= self.ids.len) return; self.store.setUnread(self.ids[self.current], false);}
/// Runs the queue's query again, staying on `keep` if it is still there/// and otherwise at the same place.fn requery(self: *Focus, keep: ?Store.Id) !void { self.ids = self.store.query(.{ .label = self.label }, &self.ids_buf); if (keep) |id| { for (self.ids, 0..) |other, i| { if (other == id) self.current = @intCast(i); } } if (self.current >= self.ids.len) self.current = @intCast(self.ids.len -| 1); try self.refresh();}
fn refresh(self: *Focus) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); try self.render(txn); try siv.txnCommit(txn);}
fn render(self: *Focus, txn: siv.Txn) !void { const empty = self.ids.len == 0; if (empty and self.mode != .goto) self.mode = .card; self.renderHeader(txn); siv.setHidden(txn, self.empty, !empty); siv.setHidden(txn, self.center, empty and self.mode != .goto); if (self.card != .null) siv.remove(txn, self.card); self.card = .null; if (!empty) try self.renderCard(txn); if (self.picker != .null) siv.remove(txn, self.picker); self.picker = .null; switch (self.mode) { .label => try self.renderLabelRow(txn), .goto => try self.renderGotoRow(txn), .card, .overview => {}, } try self.renderOverview(txn); try self.renderBar(txn);}
fn renderHeader(self: *Focus, txn: siv.Txn) void { const name = self.label.info().name; var buf: [96]u8 = undefined; const s = if (self.ids.len == 0) name else if (self.mode == .overview) mem.print(&buf, "{s} · {d} threads", .{ name, self.ids.len }) catch name else mem.print(&buf, "{s} · {d} of {d}", .{ name, self.current + 1, self.ids.len }) catch name; siv.setText(txn, self.header, s); var title_buf: [64]u8 = undefined; const title = if (self.label == .inbox) "Inbox zero" else mem.print(&title_buf, "Nothing in {s}", .{name}) catch name; siv.setText(txn, self.empty_title, title);}
fn renderCard(self: *Focus, txn: siv.Txn) !void { const thread = self.store.get(self.ids[self.current]); self.card = siv.flex(txn, self.card_slot, .{ .axis = .column, .gap = 14, .align_items = .stretch }); siv.setLayout(txn, self.card, .{ .width = kit.px(card_width), .padding = kit.edges(36) }); siv.setVisual(txn, self.card, .{ .background_token = .card_bg, .border_token = .border, .border_width = 1, .radius = kit.corners(18), .shadows = &.{ .{ .offset_y = 1, .blur = 3, .color_token = .shadow }, .{ .offset_y = 12, .blur = 32, .color_token = .shadow }, }, }); siv.setSemantics(txn, self.card, .{ .role = .group, .label = thread.subject });
const top = siv.flex(txn, self.card, .{ .gap = 10, .align_items = .center }); if (thread.unread) { const unread_dot = siv.box(txn, top); siv.setLayout(txn, unread_dot, .{ .width = kit.px(10), .height = kit.px(10) }); siv.setVisual(txn, unread_dot, .{ .background_token = .accent, .radius = kit.corners(5) }); } const from = kit.text(txn, top, thread.from, .{ .size_role = .heading, .weight = .bold, .color_token = .card_fg }); siv.setLayout(txn, from, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); var age_buf: [16]u8 = undefined; var meta_buf: [64]u8 = undefined; const unread = if (thread.unread) "unread · " else ""; const meta = mem.print(&meta_buf, "{s}{d} message{s} · {s} ago", .{ unread, thread.messages, if (thread.messages == 1) "" else "s", kit.age(&age_buf, thread.minutes_ago), }) catch ""; _ = kit.text(txn, top, meta, .{ .color_token = .muted_fg });
const subject: siv.TextStyleDesc.Options = .{ .size_role = .title, .weight = .bold, .color_token = .card_fg }; _ = kit.text(txn, self.card, thread.subject, subject); const chips = siv.flex(txn, self.card, .{ .gap = 6, .flags = .{ .wrap = true } }); var it = thread.labels.iterator(); while (it.next()) |label| { if (label != .inbox) _ = kit.chip(txn, chips, label); } const rule = siv.box(txn, self.card); siv.setLayout(txn, rule, .{ .height = kit.px(1) }); siv.setVisual(txn, rule, .{ .background_token = .separator }); const body = kit.text(txn, self.card, thread.snippet, .{ .font_size = 17, .color_token = .card_fg, .line_spacing = 1.45, }); siv.setLayout(txn, body, .{ .min_height = 80 });}
/// The user labels under the card, each with its key and a check when the/// thread has it; clicking one presses its key.fn renderLabelRow(self: *Focus, txn: siv.Txn) !void { const thread = self.store.get(self.ids[self.current]); const row = self.pickerRow(txn, "Toggle a label on this thread"); for (enums.values(Label)) |label| { const info = label.info(); if (info.kind != .user) continue; try self.pickerChip(txn, row, label, if (thread.labels.contains(label)) .on else .off, ""); }}
/// Every label with its key and how many threads it holds; clicking one/// goes there.fn renderGotoRow(self: *Focus, txn: siv.Txn) !void { const row = self.pickerRow(txn, "Go to a label"); for (enums.values(Label)) |label| { const n = self.store.count(.{ .label = label }).total; const count = try self.arena.print("{d}", .{n}); try self.pickerChip(txn, row, label, if (label == self.label) .on else .off, count); }}
/// The box under the card that holds a titled row of chips; returns the row.fn pickerRow(self: *Focus, txn: siv.Txn, title: []const u8) siv.Node { const box = siv.flex(txn, self.picker_slot, .{ .axis = .column, .gap = 10, .align_items = .center }); siv.setLayout(txn, box, .{ .max_width = card_width + 360 }); _ = kit.text(txn, box, title, .{ .size_role = .caption, .weight = .medium, .color_token = .muted_fg }); const row = siv.flex(txn, box, .{ .gap = 8, .justify = .center, .align_items = .center, .flags = .{ .wrap = true, .line_gap = true }, .line_gap = 8, }); siv.setSemantics(txn, row, .{ .role = .group, .label = title }); self.picker = box; return row;}
const Check = enum { off, on };
fn pickerChip(self: *Focus, txn: siv.Txn, row: siv.Node, label: Label, check: Check, extra: []const u8) !void { const info = label.info(); const chip = siv.flex(txn, row, .{ .gap = 7, .align_items = .center }); siv.setLayout(txn, chip, .{ .padding = .{ .start = 6, .end = 12, .top = 5, .bottom = 5 } }); siv.setVisual(txn, chip, .{ .background = if (check == .on) kit.tint(info.color, 0.18) else .{}, .background_token = if (check == .on) .none else .control_bg, .border_color = kit.tint(info.color, if (check == .on) 0.9 else 0.35), .border_width = 1, .radius = kit.corners(16), }); _ = kit.keycap(txn, chip, label.keyCap()); // Work/Hiring nests under Work: it shows as Work's child, indented by // an arrow, rather than repeating the parent's name. const name = if (mem.findScalar(u8, info.name, '/')) |slash| try self.arena.print("↳ {s}", .{info.name[slash + 1 ..]}) else info.name; _ = kit.text(txn, chip, name, .{ .weight = .medium, .color = kit.color(info.color) }); if (extra.len > 0) _ = kit.text(txn, chip, extra, .{ .size_role = .caption, .color_token = .muted_fg }); if (check == .on) _ = kit.text(txn, chip, "✓", .{ .weight = .bold, .color = kit.color(info.color) }); try self.clickable(txn, chip, .{ .key = .char(info.key) }, info.name);}
fn renderOverview(self: *Focus, txn: siv.Txn) !void { const open = self.mode == .overview; siv.setHidden(txn, self.overview, !open); if (self.overview_rows != .null) siv.remove(txn, self.overview_rows); self.overview_rows = .null; if (!open) return; var buf: [64]u8 = undefined; const title = mem.print(&buf, "{s}: the whole queue", .{self.label.info().name}) catch ""; siv.setText(txn, self.overview_title, title); self.overview_rows = siv.flex(txn, self.overview_scroll, .{ .axis = .column, .align_items = .stretch }); siv.setSemantics(txn, self.overview_rows, .{ .role = .list, .label = "Queue" }); for (self.ids, 0..) |id, i| try self.overviewRow(txn, @intCast(i), id); self.scrollOverview(txn);}
fn overviewRow(self: *Focus, txn: siv.Txn, index: u32, id: Store.Id) !void { const thread = self.store.get(id); const row = siv.flex(txn, self.overview_rows, .{ .gap = 12, .align_items = .center }); siv.setLayout(txn, row, .{ .height = kit.px(row_height), .padding = .{ .start = 14, .end = 16 } }); const selected = index == self.cursor; siv.setVisual(txn, row, .{ .background_token = if (selected) .selection_bg else .none }); const fg: siv.ColorToken = if (selected) .selection_fg else .view_fg; const here = if (index == self.current) "▶" else ""; const marker = kit.text(txn, row, here, .{ .size_role = .caption, .color_token = .accent }); siv.setLayout(txn, marker, .{ .width = kit.px(12) }); const unread_dot = siv.box(txn, row); siv.setLayout(txn, unread_dot, .{ .width = kit.px(8), .height = kit.px(8) }); siv.setVisual(txn, unread_dot, .{ .background_token = if (thread.unread) .accent else .none, .radius = kit.corners(4), }); const weight: siv.FontWeight = if (thread.unread) .bold else .regular; const from = kit.text(txn, row, thread.from, .{ .weight = weight, .color_token = fg, .flags = kit.one_line }); siv.setLayout(txn, from, .{ .width = kit.px(180), .flags = .{ .no_shrink = true } }); const subject = kit.text(txn, row, thread.subject, .{ .color_token = fg, .flags = kit.one_line }); siv.setLayout(txn, subject, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); var it = thread.labels.iterator(); while (it.next()) |label| { if (label != .inbox) _ = kit.dot(txn, row, label, 8); } var age_buf: [16]u8 = undefined; _ = kit.text(txn, row, kit.age(&age_buf, thread.minutes_ago), .{ .size_role = .caption, .color_token = .muted_fg }); try self.clickable(txn, row, .{ .jump = index }, thread.subject); siv.setSemantics(txn, row, .{ .role = .list_item, .label = thread.subject });}
/// Keeps the overview's cursor row in view.fn scrollOverview(self: *Focus, txn: siv.Txn) void { const rect = siv.nodeGetRect(self.overview_scroll) catch return; const view = if (rect.height > row_height) rect.height else 8 * row_height; const top: f32 = @floatFromInt(self.cursor * row_height); if (top < self.overview_top) self.overview_top = top; if (top + row_height > self.overview_top + view) self.overview_top = top + row_height - view; siv.scrollTo(txn, self.overview_scroll, 0, self.overview_top);}
const Button = struct { key: Key, cap: []const u8, action: []const u8 };
/// The big buttons along the bottom: the keys of the current mode.fn renderBar(self: *Focus, txn: siv.Txn) !void { if (self.bar != .null) siv.remove(txn, self.bar); var buttons: ArrayList(Button) = .empty; switch (self.mode) { .card => if (self.ids.len == 0) { try buttons.append(self.arena, .{ .key = .char('g'), .cap = "g", .action = "Go to label" }); } else { const thread = self.store.get(self.ids[self.current]); const star = if (thread.labels.contains(.starred)) "Unstar" else "Star"; try buttons.appendSlice(self.arena, &.{ .{ .key = .char('e'), .cap = "e", .action = "Archive" }, .{ .key = .char('s'), .cap = "s", .action = star }, .{ .key = .char('l'), .cap = "l", .action = "Label" }, .{ .key = .char('u'), .cap = "u", .action = if (thread.unread) "Read" else "Unread" }, .{ .key = .char('j'), .cap = "j", .action = "Skip" }, .{ .key = .char('k'), .cap = "k", .action = "Back" }, .{ .key = .char('g'), .cap = "g", .action = "Go to label" }, .{ .key = .char('o'), .cap = "o", .action = "Overview" }, }); }, .label => try buttons.append(self.arena, .{ .key = .named(.escape), .cap = "Esc", .action = "Done" }), .goto => try buttons.append(self.arena, .{ .key = .named(.escape), .cap = "Esc", .action = "Cancel" }), .overview => try buttons.appendSlice(self.arena, &.{ .{ .key = .char('j'), .cap = "j", .action = "Down" }, .{ .key = .char('k'), .cap = "k", .action = "Up" }, .{ .key = .named(.@"return"), .cap = "Enter", .action = "Open" }, .{ .key = .named(.escape), .cap = "Esc", .action = "Close" }, }), } self.bar = siv.flex(txn, self.bar_slot, .{ .gap = 10, .justify = .center, .flags = .{ .wrap = true } }); for (buttons.items) |b| try self.button(txn, b);}
fn button(self: *Focus, txn: siv.Txn, b: Button) !void { const node = siv.flex(txn, self.bar, .{ .gap = 10, .align_items = .center }); siv.setLayout(txn, node, .{ .padding = .{ .start = 10, .end = 16, .top = 10, .bottom = 10 } }); siv.setVisual(txn, node, .{ .background_token = .card_bg, .border_token = .border, .border_width = 1, .radius = kit.corners(12), .shadows = &.{.{ .offset_y = 1, .blur = 2, .color_token = .shadow }}, }); const cap = siv.flex(txn, node, .{ .justify = .center }); siv.setLayout(txn, cap, .{ .min_width = 26, .padding = .{ .start = 6, .end = 6, .top = 2, .bottom = 2 } }); siv.setVisual(txn, cap, .{ .background_token = .control_bg, .border_token = .border, .border_width = 1, .radius = kit.corners(6), }); _ = kit.text(txn, cap, b.cap, .{ .family = "monospace", .weight = .bold, .color_token = .control_fg }); _ = kit.text(txn, node, b.action, .{ .weight = .medium, .color_token = .card_fg }); try self.clickable(txn, node, .{ .key = b.key }, b.action);}
/// Makes `node` a button that runs `action` when clicked. Every action is/// a key press or the overview's jump, which j, k and Enter also do.fn clickable(self: *Focus, txn: siv.Txn, node: siv.Node, action: Action, name: []const u8) !void { const Click = struct { const Self = @This();
focus: *Focus, action: Action,
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: *Self = @ptrCast(@alignCast(user_data)); const result = switch (click.action) { .key => |key| click.focus.onKey(key), .jump => |index| click.focus.jump(index), }; _ = result catch |err| { log.err("click: {t}", .{err}); return .pass; }; return .handled; } }; const click = try self.arena.create(Click); click.* = .{ .focus = self, .action = action }; siv.setSemantics(txn, node, .{ .role = .button, .label = name }); siv.setCursor(txn, node, .pointer); siv.on(txn, node, .pointer, Click.call, null, click);}