//! What every prototype shares: one key dispatcher per prototype, a hint //! bar that makes each key clickable, label chips, and text helpers. //! //! The parity rule: a prototype 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 sample = @import("sample.zig"); const mem = std.mem; const Allocator = std.mem.Allocator; const log = std.log.scoped(.proto); pub const Label = sample.Label; const Rgb = sample.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, pub fn char(c: u8) Key { return .{ .code = c, .mods = .{} }; } pub fn named(k: siv.Key) Key { return .{ .code = @backingInt(k), .mods = .{} }; } pub fn ctrl(c: u8) Key { return .{ .code = c, .mods = .{ .ctrl = true } }; } /// 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 shot scripts: `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 }; 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); } /// Whether keyboard focus is on `node` or inside it, such as in a text /// field's editor. A prototype'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); } /// A key's name drawn as a keycap. pub fn keycap(txn: siv.Txn, parent: siv.Node, cap: []const u8) siv.Node { const box = siv.flex(txn, parent, .{}); siv.setLayout(txn, box, .{ .padding = .{ .start = 6, .end = 6, .top = 1, .bottom = 1 } }); siv.setVisual(txn, box, .{ .background_token = .control_bg, .border_token = .border, .border_width = 1, .radius = corners(4), }); _ = text(txn, box, cap, .{ .size_role = .caption, .family = "monospace", .color_token = .control_fg }); return box; } /// A label's name on a tint of its color. pub fn chip(txn: siv.Txn, parent: siv.Node, label: Label) siv.Node { const info = label.info(); const box = siv.flex(txn, parent, .{}); siv.setLayout(txn, box, .{ .padding = .{ .start = 7, .end = 7, .top = 1, .bottom = 1 } }); siv.setVisual(txn, box, .{ .background = tint(info.color, 0.16), .radius = corners(9) }); _ = text(txn, box, info.name, .{ .size_role = .caption, .weight = .medium, .color = color(info.color) }); return box; } /// A dot in a label's color. pub fn dot(txn: siv.Txn, parent: siv.Node, label: Label, 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(label.info().color), .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; } /// "7m", "3h", "2d": the age of a thread's newest message. pub fn age(buf: []u8, minutes: u32) []const u8 { return if (minutes < 60) mem.print(buf, "{d}m", .{minutes}) catch "?" else if (minutes < 24 * 60) mem.print(buf, "{d}h", .{minutes / 60}) catch "?" else mem.print(buf, "{d}d", .{minutes / (24 * 60)}) catch "?"; } 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 }; }