Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419//! What the views share: one key dispatcher per view, a hint bar that//! makes each key clickable, label chips, and text helpers.//!//! The parity rule: a view handles keys in `onKey(self, Key) !bool`. The//! window's root sends it every key press first (a capture handler), and//! every hint calls the same function when clicked, so anything a key//! does, a click does too.
const std = @import("std");const siv = @import("siv");const zeit = @import("zeit");const Mailbox = @import("Mailbox.zig");const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const log = std.log.scoped(.kit);const Rgb = Mailbox.Rgb;
/// A key press: an XKB keysym (a character key's is its code point) and/// the modifiers held.pub const Key = struct { code: i32, mods: siv.Modifiers, /// The physical key, for what lasts while it is held; 0 for a key /// pressed by a click or a test. keycode: u32,
pub fn char(c: u8) Key { return .{ .code = c, .mods = .{}, .keycode = 0 }; }
pub fn named(k: siv.Key) Key { return .{ .code = @backingInt(k), .mods = .{}, .keycode = 0 }; }
pub fn ctrl(c: u8) Key { return .{ .code = c, .mods = .{ .ctrl = true }, .keycode = 0 }; }
/// The character key `c`, without Ctrl or Alt. pub fn is(self: Key, c: u8) bool { return self.code == c and !self.mods.ctrl and !self.mods.alt; }
pub fn isNamed(self: Key, k: siv.Key) bool { return self.code == @backingInt(k); }
pub fn isCtrl(self: Key, c: u8) bool { return self.code == c and self.mods.ctrl; }
/// The printable ASCII character it types, if any. pub fn printable(self: Key) ?u8 { if (self.mods.ctrl or self.mods.alt) return null; return if (self.code >= ' ' and self.code < 0x7f) @intCast(self.code) else null; }};
/// A sequence of character keys, for tests: `keys("jjl")`.pub fn keys(comptime s: []const u8) [s.len]Key { var out: [s.len]Key = undefined; for (s, 0..) |c, i| out[i] = .char(c); return out;}
/// A key and what it does, shown in a hint bar. Clicking it presses it.pub const Hint = struct { key: Key, /// What the keycap shows: "j", "Enter", "/". cap: []const u8, action: []const u8,};
/// Sends every key press in `window` to `self.onKey` before the focused/// node sees it. Keys it declines go on as usual, so an editor still types.pub fn keymap(comptime T: type, txn: siv.Txn, root: siv.Node, self: *T) void { siv.onCapture(txn, root, .key, struct { fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (!event.flags.pressed) return .pass; const t: *T = @ptrCast(@alignCast(user_data)); const key: Key = .{ .code = @backingInt(event.keysym), .mods = event.modifiers, .keycode = event.keycode }; const used = t.onKey(key) catch |err| { log.err("key {d}: {t}", .{ key.code, err }); return .pass; }; return if (used) .handled else .pass; } }.call, null, self);}
/// Calls `f(self, event)` for one event type; true means handled.pub fn on( comptime T: type, txn: siv.Txn, node: siv.Node, event_type: siv.EventType, self: *T, comptime f: fn (*T, *const siv.Event) anyerror!bool,) void { siv.on(txn, node, event_type, struct { fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { const t: *T = @ptrCast(@alignCast(user_data)); const used = f(t, event) catch |err| { log.err("{t} handler: {t}", .{ event.type, err }); return .pass; }; return if (used) .handled else .pass; } }.call, null, self);}
/// Deletes from the caret to the end of `field`, a one-line text field,/// as Ctrl+K does in Emacs and the shells.pub fn killLine(field: siv.Node) !void { var caret: u32 = 0; var anchor: u32 = 0; try siv.nodeGetSelection(field, &caret, &anchor); // An empty buffer asks only the text's length. const len: u32 = @intCast(try siv.nodeGetText(field, &.{})); if (len > caret) try siv.editorReplace(field, caret, len, "");}
/// Whether keyboard focus is on `node` or inside it, such as in a text/// field's editor. A view's onKey lets keys through while typing.pub fn focusWithin(window: siv.Window, node: siv.Node) bool { var n = siv.windowGetFocus(window); while (n != .null) : (n = siv.nodeGetRelated(n, .parent)) { if (n == node) return true; } return false;}
/// A row of keycaps with their actions, each one clickable. `arena`/// holds what each hint's click needs for as long as the window lives.pub fn hintBar( comptime T: type, arena: Allocator, txn: siv.Txn, parent: siv.Node, self: *T, hints: []const Hint,) Allocator.Error!siv.Node { const bar = siv.flex(txn, parent, .{ .gap = 16, .align_items = .center, .flags = .{ .wrap = true } }); siv.setLayout(txn, bar, .{ .padding = .{ .start = 16, .end = 16, .top = 8, .bottom = 8 } }); for (hints) |hint| try addHint(T, arena, txn, bar, self, hint); return bar;}
fn addHint(comptime T: type, arena: Allocator, txn: siv.Txn, bar: siv.Node, self: *T, hint: Hint) Allocator.Error!void { const item = siv.flex(txn, bar, .{ .gap = 6, .align_items = .center }); try clickable(T, Key, press(T), arena, txn, item, self, hint.key, hint.action); _ = keycap(txn, item, hint.cap); _ = text(txn, item, hint.action, .{ .size_role = .caption, .color_token = .muted_fg });}
/// A click handler that presses `key`.pub fn press(comptime T: type) fn (*T, Key, u32) anyerror!void { return struct { fn call(self: *T, key: Key, _: u32) anyerror!void { _ = try self.onKey(key); } }.call;}
/// Makes `node` a button named `name` whose primary click calls/// `f(self, arg, clicks)`, where `clicks` is 2 for a double click. `arena`/// holds `arg` for as long as the window lives.pub fn clickable( comptime T: type, comptime A: type, comptime f: fn (*T, A, u32) anyerror!void, arena: Allocator, txn: siv.Txn, node: siv.Node, self: *T, arg: A, name: []const u8,) Allocator.Error!void { const Click = struct { const Self = @This();
target: *T, arg: A,
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)); f(click.target, click.arg, event.clicks) catch |err| { log.err("click: {t}", .{err}); return .pass; }; return .handled; } }; const click = try arena.create(Click); click.* = .{ .target = self, .arg = arg }; siv.setSemantics(txn, node, .{ .role = .button, .label = name }); siv.setCursor(txn, node, .pointer); siv.on(txn, node, .pointer, Click.call, null, click);}
/// siv's push button, which shows hover, press, and focus itself, named/// `name` for assistive technology; activating it, by a click or a key,/// calls `f(self, arg, 1)`. `arena` holds `arg` for as long as the button.pub fn button( comptime T: type, comptime A: type, comptime f: fn (*T, A, u32) anyerror!void, arena: Allocator, txn: siv.Txn, parent: siv.Node, desc: siv.ButtonDesc.Options, name: []const u8, self: *T, arg: A,) Allocator.Error!siv.Node { const node = siv.pushButton(txn, parent, desc); siv.setSemantics(txn, node, .{ .role = .button, .label = name }); try handle(T, A, f, arena, txn, node, .activate, self, arg); return node;}
/// Calls `f(self, arg, 1)` on `node`'s `event_type` events, such as a/// widget's activate or close, and handles them. `arena` holds `arg` for/// as long as the node.pub fn handle( comptime T: type, comptime A: type, comptime f: fn (*T, A, u32) anyerror!void, arena: Allocator, txn: siv.Txn, node: siv.Node, event_type: siv.EventType, self: *T, arg: A,) Allocator.Error!void { const Handler = struct { const Self = @This();
target: *T, arg: A,
fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { const handler: *Self = @ptrCast(@alignCast(user_data)); f(handler.target, handler.arg, 1) catch |err| { log.err("{t} handler: {t}", .{ event.type, err }); return .pass; }; return .handled; } }; const handler = try arena.create(Handler); handler.* = .{ .target = self, .arg = arg }; siv.on(txn, node, event_type, Handler.call, null, handler);}
/// A key's name in the accent, as a terminal's statusline shows its keys.pub fn keycap(txn: siv.Txn, parent: siv.Node, cap: []const u8) siv.Node { const box = siv.flex(txn, parent, .{}); _ = text(txn, box, cap, .{ .size_role = .caption, .weight = .bold, .color_token = .accent }); return box;}
/// A label's name in a pill of the theme's hue nearest its color.pub fn chip(txn: siv.Txn, parent: siv.Node, info: *const Mailbox.LabelInfo, flags: siv.ChipFlags) siv.Node { return siv.chip(txn, parent, .{ .label = info.name, .color = hue(info.color), .flags = flags });}
/// The theme's hue nearest a color, so labels follow the theme as the/// rest of the window does: Gmail's orange is the theme's yellow, its/// purple the theme's magenta. A gray is none, the neutral chip.pub fn hue(rgb: Rgb) siv.ColorToken { const r: f32 = @floatFromInt(rgb.r); const g: f32 = @floatFromInt(rgb.g); const b: f32 = @floatFromInt(rgb.b); const max = @max(r, g, b); const range = max - @min(r, g, b); if (max == 0 or range / max < 0.3) return .none; const sector = if (max == r) @mod((g - b) / range, 6) else if (max == g) (b - r) / range + 2 else (r - g) / range + 4; const degrees = sector * 60; return if (degrees < 18 or degrees >= 340) .red else if (degrees < 75) .yellow else if (degrees < 160) .green else if (degrees < 200) .cyan else if (degrees < 250) .blue else .magenta;}
/// Small, as Omarchy's windows are square: siv's cards'.pub const card_radius = 6;
/// A dot in a label's color.pub fn dot(txn: siv.Txn, parent: siv.Node, rgb: Rgb, size: f32) siv.Node { const node = siv.box(txn, parent); siv.setLayout(txn, node, .{ .width = px(size), .height = px(size) }); siv.setVisual(txn, node, .{ .background = color(rgb), .radius = corners(size / 2) }); return node;}
pub fn text(txn: siv.Txn, parent: siv.Node, s: []const u8, style: siv.TextStyleDesc.Options) siv.Node { const node = siv.text(txn, parent, s); siv.setTextStyle(txn, node, style); return node;}
/// How old mail sent at `then` is at `now`, both in seconds since the/// epoch, for a column: "7m", "3h", "5d" within a week, then `day`.pub fn age(buf: []u8, app: siv.App, zone: *const zeit.TimeZone, now: i64, then: i64) []const u8 { const minutes = @max(now - then, 0) / 60; if (minutes < 60) return mem.print(buf, "{d}m", .{minutes}) catch "?"; if (minutes < 24 * 60) return mem.print(buf, "{d}h", .{minutes / 60}) catch "?"; if (minutes < week_minutes) return mem.print(buf, "{d}d", .{minutes / (24 * 60)}) catch "?"; return day(buf, app, zone, now, then);}
/// `age` for a sentence, as the app's language says it: "3 hours ago",/// "now" within a minute, then `day`.pub fn ago(buf: []u8, app: siv.App, zone: *const zeit.TimeZone, now: i64, then: i64) []const u8 { const seconds = @max(now - then, 0); if (seconds / 60 >= week_minutes) return day(buf, app, zone, now, then); // Whole minutes, so the text changes once a minute, not every second. const n = siv.appFormatTimeAgo(app, @intCast(seconds - @mod(seconds, 60)), .long, buf) catch return "?"; return buf[0..n];}
/// The day mail sent at `then` came in `zone`, as the app's language/// writes it: "Oct 20", with the year when it is not `now`'s, "Nov 3, 2025".pub fn day(buf: []u8, app: siv.App, zone: *const zeit.TimeZone, now: i64, then: i64) []const u8 { const came = zeit.instant(.{ .unix_timestamp = then }, zone).time(); const year = zeit.instant(.{ .unix_timestamp = now }, zone).time().year; const days = zeit.daysFromCivil(.{ .year = came.year, .month = came.month, .day = came.day }); const style: siv.DateStyle = if (came.year == year) .month_day else .month_day_year; const n = siv.appFormatDate(app, days, style, buf) catch return "?"; return buf[0..n];}
const week_minutes = 7 * 24 * 60;
pub fn color(rgb: Rgb) siv.Color { return tint(rgb, 1);}
pub fn tint(rgb: Rgb, alpha: f32) siv.Color { const r: f32 = @floatFromInt(rgb.r); const g: f32 = @floatFromInt(rgb.g); const b: f32 = @floatFromInt(rgb.b); return .{ .r = r / 255, .g = g / 255, .b = b / 255, .a = alpha };}
pub fn corners(r: f32) siv.Corners { return .{ .top_start = r, .top_end = r, .bottom_end = r, .bottom_start = r };}
pub fn px(v: f32) siv.Dimension { return .{ .unit = .px, .value = v };}
pub const whole: siv.Dimension = .{ .unit = .fraction, .value = 1 };
/// Text flags for a line that ends in an ellipsis rather than wrapping.pub const one_line: siv.TextFlags = .{ .no_wrap = true, .ellipsize = true };
pub fn edges(v: f32) siv.Edges { return .{ .start = v, .end = v, .top = v, .bottom = v };}
test "mail a week old or more shows the day it came, in the local zone and the app's language" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); try siv.appSetLanguage(app, "en", .ltr); const chicago: zeit.TimeZone = .{ .posix = try .parse("CST6CDT,M3.2.0,M11.1.0") }; const now = 1794139200; // 2026-11-08 12:00 UTC const cases = [_]struct { i64, i64, []const u8, []const u8 }{ .{ now, now - 59, "0m", "now" }, .{ now, 1794128340, "3h", "3 hours ago" }, // 08:59 .{ now, 1793534460, "6d", "6 days ago" }, // 2026-11-01 12:01, a minute short of a week .{ now, 1793534400, "Nov 1", "Nov 1" }, // 2026-11-01 12:00 .{ now, 1792465200, "Oct 19", "Oct 19" }, // 2026-10-20 03:00 UTC, the 19th in Chicago .{ now, 1762171200, "Nov 3, 2025", "Nov 3, 2025" }, // 2027-01-01 03:00 UTC, still 2026 in Chicago. .{ 1800446400, 1798772400, "Dec 31, 2026", "Dec 31, 2026" }, }; for (cases) |c| { var buf: [64]u8 = undefined; try testing.expectEqualStrings(c[2], age(&buf, app, &chicago, c[0], c[1])); try testing.expectEqualStrings(c[3], ago(&buf, app, &chicago, c[0], c[1])); } try siv.appSetLanguage(app, "de", .ltr); var buf: [64]u8 = undefined; try testing.expectEqualStrings("3. Nov. 2025", age(&buf, app, &chicago, now, 1762171200));}
test "a label's color is the theme's nearest hue, a gray none" { const cases = [_]struct { u24, siv.ColorToken }{ .{ 0x2563eb, .blue }, .{ 0x7c3aed, .magenta }, .{ 0x16a34a, .green }, .{ 0xea580c, .yellow }, .{ 0x0891b2, .cyan }, .{ 0xdb2777, .magenta }, .{ 0xb91c1c, .red }, .{ 0x64748b, .none }, .{ 0x000000, .none }, }; for (cases) |c| { const rgb: Rgb = .{ .r = @truncate(c[0] >> 16), .g = @truncate(c[0] >> 8), .b = @truncate(c[0]) }; try testing.expectEqual(c[1], hue(rgb)); }}