//! 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); }