Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A question over everything else, which has to be answered before//! anything behind it can be used: a title, what is being asked, and//! buttons to answer with. It may ask for a line of text or two — a//! branch's name, a tag's name and message — or for one of a few choices,//! as gitk's reset asks how far.//!//! Like the menu, it knows nothing of what its answers do. The application//! opens it, passes it the pointer and the keys it has no use for itself,//! and is told which button was chosen, by its index, or that it was//! dismissed.
const std = @import("std");const Allocator = std.mem.Allocator;const aerie = @import("aerie");const Canvas = aerie.gpu.Canvas;const Rect = Canvas.Rect;
const Text = @import("Text.zig");const TextField = @import("TextField.zig");const theme = @import("theme.zig");
const Dialog = @This();
pub const max_buttons = 3;pub const max_choices = 4;pub const max_fields = 2;
/// A line of text to ask for: what it is, if that needs saying, and what/// it starts as.pub const Field = struct { label: ?[]const u8 = null, text: []const u8 = "",};
/// What to ask.pub const Spec = struct { title: []const u8, message: []const u8, /// The lines of text to ask for, top to bottom. The first has the /// keyboard, with what it starts as selected, so that typing replaces /// it. fields: []const Field = &.{}, /// One of these to choose, starting with `choice`. choices: []const []const u8 = &.{}, choice: usize = 0, /// The buttons, left to right; at least one. buttons: []const []const u8, /// The button Return presses, drawn as the one expected. default: usize = 0,};
/// How it was answered.pub const Answer = union(enum) { /// Escape, or the window closed: as if nothing had been asked. dismissed, button: usize,};
/// What is under the pointer.pub const Hit = union(enum) { field: usize, choice: usize, button: usize,};
/// Everything it shows, its own; and the message as it was last wrapped.arena: std.heap.ArenaAllocator,wrapped: std.heap.ArenaAllocator,title: []const u8,message: []const u8,fields: [max_fields]TextField = undefined,field_labels: [max_fields]?[]const u8 = undefined,field_count: usize = 0,/// The field with the keyboard.focus: usize = 0,choices: []const []const u8,choice: usize,buttons: []const []const u8,default: usize,hovered: ?Hit = null,
/// Where it all is, from `layout`.rect: Rect = .{ .x = 0, .y = 0, .width = 0, .height = 0 },lines: []const []const u8 = &.{},title_y: f32 = 0,message_y: f32 = 0,field_rects: [max_fields]Rect = undefined,label_ys: [max_fields]f32 = undefined,choice_rects: [max_choices]Rect = undefined,button_rects: [max_buttons]Rect = undefined,line_height: f32 = 1,padding: f32 = 0,
pub fn init(gpa: Allocator, spec: Spec) Allocator.Error!Dialog { std.debug.assert(spec.buttons.len > 0 and spec.buttons.len <= max_buttons); std.debug.assert(spec.choices.len <= max_choices); std.debug.assert(spec.fields.len <= max_fields); var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); const choices = try a.alloc([]const u8, spec.choices.len); for (choices, spec.choices) |*to, from| to.* = try a.dupe(u8, from); const buttons = try a.alloc([]const u8, spec.buttons.len); for (buttons, spec.buttons) |*to, from| to.* = try a.dupe(u8, from); var d: Dialog = .{ .arena = arena, .wrapped = .init(gpa), .title = try a.dupe(u8, spec.title), .message = try a.dupe(u8, spec.message), .choices = choices, .choice = @min(spec.choice, @max(choices.len, 1) - 1), .buttons = buttons, .default = @min(spec.default, buttons.len - 1), }; errdefer for (d.fields[0..d.field_count]) |*f| f.deinit(gpa); for (spec.fields, 0..) |field, i| { d.fields[i] = .{}; d.field_labels[i] = if (field.label) |l| try a.dupe(u8, l) else null; d.field_count += 1; try d.fields[i].insert(gpa, field.text); } if (d.field_count > 0) d.fields[0].selectAll(); return d;}
pub fn deinit(d: *Dialog, gpa: Allocator) void { for (d.fields[0..d.field_count]) |*f| f.deinit(gpa); d.wrapped.deinit(); d.arena.deinit(); d.* = undefined;}
/// The text typed in the field `i`.pub fn fieldText(d: *const Dialog, i: usize) []const u8 { return d.fields[i].text();}
/// The field with the keyboard, if any is asked for.pub fn focused(d: *Dialog) ?*TextField { return if (d.field_count > 0) &d.fields[d.focus] else null;}
/// Gives the keyboard to the next field, or with `delta` -1 the one/// before, round from the last to the first.pub fn focusNext(d: *Dialog, delta: isize) void { if (d.field_count == 0) return; const n: isize = @intCast(d.field_count); d.focus = @intCast(@mod(@as(isize, @intCast(d.focus)) + delta, n)); d.fields[d.focus].selectAll();}
/// Moves the choice by `delta`, as far as there are choices.pub fn moveChoice(d: *Dialog, delta: isize) void { if (d.choices.len == 0) return; const to = std.math.clamp(@as(isize, @intCast(d.choice)) + delta, 0, @as(isize, @intCast(d.choices.len - 1))); d.choice = @intCast(to);}
/// Places it in the middle of `bounds`, as wide as its title and buttons/// need and at least a comfortable width, with the message wrapped to fit.pub fn layout(d: *Dialog, text: *Text, bounds: Rect, scale: f32) !void { const pad = @round(18 * scale); const gap = @round(10 * scale); const lh = text.lineHeight(.ui); const control_h = @round(lh + 10 * scale); const button_pad = @round(16 * scale); d.padding = pad; d.line_height = lh;
var buttons_w: f32 = 0; var button_ws: [max_buttons]f32 = undefined; for (d.buttons, 0..) |b, i| { button_ws[i] = @max(@round(80 * scale), try text.measure(.ui, b) + 2 * button_pad); buttons_w += button_ws[i] + (if (i > 0) gap else 0); } var content_w = @max(try text.measure(.ui, d.title), buttons_w); for (d.choices) |c| content_w = @max(content_w, choiceIndent(scale) + try text.measure(.ui, c)); const widest = @max(@round(420 * scale), content_w + 2 * pad); const width = @max(0, @min(widest, bounds.width - @round(32 * scale))); const inner = @max(1, width - 2 * pad);
_ = d.wrapped.reset(.retain_capacity); d.lines = try wrap(d.wrapped.allocator(), text, d.message, inner);
const choice_h = @round(lh + 8 * scale); var h = pad + lh + gap + @as(f32, @floatFromInt(d.lines.len)) * lh; for (d.field_labels[0..d.field_count]) |label| { h += gap + control_h; if (label != null) h += lh + @round(4 * scale); } if (d.choices.len > 0) h += gap + @as(f32, @floatFromInt(d.choices.len)) * choice_h; h += 2 * gap + control_h + pad;
d.rect = .{ .x = @round(bounds.x + (bounds.width - width) / 2), .y = @round(bounds.y + @max(0, (bounds.height - h) / 2)), .width = width, .height = h, }; const x = d.rect.x + pad; var y = d.rect.y + pad; d.title_y = y; y += lh + gap; d.message_y = y; y += @as(f32, @floatFromInt(d.lines.len)) * lh; for (d.field_labels[0..d.field_count], 0..) |label, i| { y += gap; if (label != null) { d.label_ys[i] = y; y += lh + @round(4 * scale); } d.field_rects[i] = .{ .x = x, .y = y, .width = inner, .height = control_h }; y += control_h; } if (d.choices.len > 0) { y += gap; for (0..d.choices.len) |i| { d.choice_rects[i] = .{ .x = x, .y = y, .width = inner, .height = choice_h }; y += choice_h; } } y += 2 * gap; // The buttons along the bottom, at the right. var bx = d.rect.right() - pad; var i = d.buttons.len; while (i > 0) { i -= 1; bx -= button_ws[i]; d.button_rects[i] = .{ .x = bx, .y = y, .width = button_ws[i], .height = control_h }; bx -= gap; }}
/// What is at `x`, `y`.pub fn hitAt(d: *const Dialog, x: f32, y: f32) ?Hit { for (d.button_rects[0..d.buttons.len], 0..) |r, i| if (r.contains(x, y)) return .{ .button = i }; for (d.choice_rects[0..d.choices.len], 0..) |r, i| if (r.contains(x, y)) return .{ .choice = i }; for (d.field_rects[0..d.field_count], 0..) |r, i| if (r.contains(x, y)) return .{ .field = i }; return null;}
/// Follows the pointer; true when what is lit changed.pub fn hover(d: *Dialog, x: f32, y: f32) bool { const h = d.hitAt(x, y); defer d.hovered = h; return !std.meta.eql(h, d.hovered);}
/// A press at `x`, `y`: a button answers; a choice is chosen; a field/// takes the keyboard, and the caret.pub fn press(d: *Dialog, text: *Text, x: f32, y: f32, scale: f32) ?Answer { switch (d.hitAt(x, y) orelse return null) { .button => |i| return .{ .button = i }, .choice => |i| d.choice = i, .field => |i| { d.focus = i; d.fields[i].place(text, x - d.field_rects[i].x - @round(5 * scale)) catch {}; }, } return null;}
pub fn draw(d: *Dialog, canvas: *Canvas, text: *Text, bounds: Rect, scale: f32) !void { try d.layout(text, bounds, scale); const saved = canvas.clip; defer canvas.setClip(saved); canvas.setClip(null); // What is behind it, dimmed: not to be used until it is answered. try canvas.rect(bounds.x, bounds.y, bounds.width, bounds.height, theme.current.menu_shadow); const r = d.rect; const s = @round(4 * scale); try canvas.rect(r.x + s, r.y + s, r.width, r.height, theme.current.menu_shadow); try canvas.rect(r.x, r.y, r.width, r.height, theme.current.list_background); try canvas.frame(r.x, r.y, r.width, r.height, @max(1, @round(scale)), theme.current.divider);
const x = r.x + d.padding; const lh = d.line_height; _ = try text.draw(canvas, .ui, d.title, x, d.title_y + text.baselineIn(.ui, lh), theme.current.foreground); const clipped = canvas.pushClip(.{ .x = x, .y = r.y, .width = @max(0, r.width - 2 * d.padding), .height = r.height }); for (d.lines, 0..) |line, i| { const top = d.message_y + @as(f32, @floatFromInt(i)) * lh; _ = try text.draw(canvas, .ui, line, x, top + text.baselineIn(.ui, lh), theme.current.dim_foreground); } canvas.setClip(clipped);
for (d.fields[0..d.field_count], 0..) |*f, i| { if (d.field_labels[i]) |label| _ = try text.draw(canvas, .ui, label, x, d.label_ys[i] + text.baselineIn(.ui, lh), theme.current.foreground); try f.draw(canvas, text, d.field_rects[i], i == d.focus, scale); }
for (d.choices, 0..) |label, i| { const cr = d.choice_rects[i]; const inner = canvas.pushClip(cr); defer canvas.setClip(inner); if (std.meta.eql(d.hovered, Hit{ .choice = i })) try canvas.rect(cr.x, cr.y, cr.width, cr.height, theme.current.control_hover); // A radio button: a ring, filled when chosen. const radius = @round(6 * scale); const cx = cr.x + @round(4 * scale) + radius; const cy = cr.y + cr.height / 2; try canvas.disc(cx, cy, radius, theme.current.dim_foreground); try canvas.disc(cx, cy, radius - @max(1, @round(1.5 * scale)), theme.current.list_background); if (d.choice == i) try canvas.disc(cx, cy, radius - @round(3 * scale), theme.current.selection_background); _ = try text.draw(canvas, .ui, label, cr.x + choiceIndent(scale), cr.y + text.baselineIn(.ui, cr.height), theme.current.foreground); }
for (d.buttons, 0..) |label, i| { const br = d.button_rects[i]; const lit = std.meta.eql(d.hovered, Hit{ .button = i }); const expected = i == d.default; const fill = if (expected) theme.current.selection_background else if (lit) theme.current.control_hover else theme.current.bar_background; try canvas.rect(br.x, br.y, br.width, br.height, fill); try canvas.frame(br.x, br.y, br.width, br.height, @max(1, @round(scale)), if (expected and lit) theme.current.foreground else theme.current.divider); const w = try text.measure(.ui, label); const color = if (expected) theme.current.selection_foreground else theme.current.foreground; _ = try text.draw(canvas, .ui, label, @round(br.x + (br.width - w) / 2), br.y + text.baselineIn(.ui, br.height), color); }}
/// How far a choice's label is from the left of its row: past its radio/// button.fn choiceIndent(scale: f32) f32 { return @round(4 * scale) + 2 * @round(6 * scale) + @round(8 * scale);}
/// `s` in lines no wider than `width`, broken where it breaks and between/// words; a word wider than that alone is a line of its own. The lines/// point into `s`.fn wrap(a: Allocator, measurer: anytype, s: []const u8, width: f32) ![]const []const u8 { var lines: std.ArrayList([]const u8) = .empty; var paragraphs = std.mem.splitScalar(u8, s, '\n'); while (paragraphs.next()) |p| { var start: usize = 0; // The end of the line so far, and where the next word starts. var end: usize = 0; var i: usize = 0; while (i <= p.len) { const word_end = std.mem.findScalarPos(u8, p, i, ' ') orelse p.len; if (end > start and try measurer.measure(.ui, p[start..word_end]) > width) { try lines.append(a, p[start..end]); start = i; } end = word_end; i = word_end + 1; } try lines.append(a, p[start..end]); } return lines.toOwnedSlice(a);}
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Ten pixels a byte.const Fixed = struct { pub fn measure(_: Fixed, _: Text.Face, s: []const u8) !f32 { return @floatFromInt(s.len * 10); }};
test "a message is wrapped between words, and where it breaks" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const lines = try wrap(arena.allocator(), Fixed{}, "one two three\nfour", 80); try testing.expectEqual(@as(usize, 3), lines.len); try testing.expectEqualStrings("one two", lines[0]); try testing.expectEqualStrings("three", lines[1]); try testing.expectEqualStrings("four", lines[2]); // A word too wide for a line is a line by itself. const long = try wrap(arena.allocator(), Fixed{}, "a abcdefghijk b", 50); try testing.expectEqual(@as(usize, 3), long.len); try testing.expectEqualStrings("abcdefghijk", long[1]);}
test "the choice stays among the choices" { var d = try Dialog.init(testing.allocator, .{ .title = "t", .message = "m", .choices = &.{ "a", "b", "c" }, .choice = 1, .buttons = &.{ "OK", "Cancel" }, .fields = &.{ .{ .text = "name" }, .{ .label = "Message" } }, }); defer d.deinit(testing.allocator); d.moveChoice(5); try testing.expectEqual(@as(usize, 2), d.choice); d.moveChoice(-9); try testing.expectEqual(@as(usize, 0), d.choice); try testing.expectEqualStrings("name", d.fieldText(0)); // The first field has the keyboard, its text selected so that typing // replaces it; Tab goes round them. try testing.expect(d.focused().? == &d.fields[0]); try testing.expect(d.fields[0].all_selected); d.focusNext(1); try testing.expectEqual(@as(usize, 1), d.focus); d.focusNext(1); try testing.expectEqual(@as(usize, 0), d.focus); d.focusNext(-1); try testing.expectEqual(@as(usize, 1), d.focus);}