// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! A confirmation: harrier asking the user whether to do what it was asked //! to — paste text that would run as it arrives, or let a program use the //! clipboard — drawn over the pane it is about, and waiting there until it //! is answered. //! //! A pane has at most one. What happens to a second question while one //! waits is `replaces`'s to say; what is asked, and the answers on offer, //! are here, and what an answer does is the window's. const std = @import("std"); const Allocator = std.mem.Allocator; const vt = @import("ghostty-vt"); const Session = @import("termio/Session.zig"); const Confirmation = @This(); question: Question, /// The answer Enter gives: an index into `answers()`. It starts on the one /// that does nothing, so that a stray Enter does no harm. selected: usize, /// The answer under the pointer, if one is. hover: ?usize = null, /// Where it was last drawn, in physical pixels, and each answer's button /// in it: what the pointer is over. Empty until drawn. box: Rect = .{}, buttons: [max_answers]Rect = @splat(.{}), /// The text that would be pasted or written, in rows, and where it was /// last drawn: the first row shown, how many are, and the well they are /// shown in. Empty for a question with no text. layout: Layout = .{}, scroll: usize = 0, shown_rows: usize = 0, well: Rect = .{}, /// What the wheel has turned that is not yet a whole row. scroll_rest: f32 = 0, pub const Rect = struct { x: u32 = 0, y: u32 = 0, w: u32 = 0, h: u32 = 0, pub fn contains(r: Rect, x: f32, y: f32) bool { const fx: f32 = @floatFromInt(r.x); const fy: f32 = @floatFromInt(r.y); return x >= fx and y >= fy and x < fx + @as(f32, @floatFromInt(r.w)) and y < fy + @as(f32, @floatFromInt(r.h)); } }; /// Which clipboard: the one copied to with a key, or the selection. pub const Clipboard = enum { standard, primary }; pub const Question = union(enum) { /// An unsafe paste: the text, owned, and where it came from. paste: struct { text: []u8, from: PasteFrom }, /// A program asking to put `contents`, owned (see /// `Session.dupeContents`), on a clipboard, and how to answer it; and /// the image among them to show, if one can be (see `imagePreview`). write: struct { contents: []vt.clipboard.Content, clipboard: Clipboard, reply: vt.clipboard.Write.Deferred, preview: ?Preview = null }, /// A program asking to read a clipboard, and how to answer it. read: struct { clipboard: Clipboard, reply: vt.clipboard.Read.Deferred }, pub fn deinit(q: Question, gpa: Allocator) void { switch (q) { .paste => |p| gpa.free(p.text), .write => |wr| { Session.freeContents(gpa, wr.contents); if (wr.preview) |pv| gpa.free(pv.rgba); }, .read => {}, } } }; pub const PasteFrom = enum { clipboard, primary, drop }; pub const Answer = enum { /// Paste, or let the program have it. yes, /// Let the program have it, and not ask again for the pane. yes_for_pane, /// Do nothing: Cancel, or Deny. no, }; pub const max_answers = 3; /// Asks `q`, the answer that does nothing selected. Takes `q`. pub fn init(q: Question) Confirmation { const as = answersFor(q); return .{ .question = q, .selected = std.mem.findScalar(Answer, as, .no).? }; } pub fn deinit(c: *Confirmation, gpa: Allocator) void { c.question.deinit(gpa); c.layout.deinit(gpa); } /// The text the question shows, which would be pasted or written: none /// for a read, or a write of something other than text. pub fn shownText(c: *const Confirmation) []const u8 { return switch (c.question) { .paste => |x| x.text, .write => |x| for (x.contents) |y| { if (vt.clipboard.isTextMime(y.mime)) break y.data; } else "", .read => "", }; } /// Scrolls the text `by` rows, down when positive, no further than its /// ends. pub fn scrollBy(c: *Confirmation, by: isize) void { const last = c.layout.len() -| c.shown_rows; const to = @as(isize, @intCast(c.scroll)) + by; c.scroll = @intCast(std.math.clamp(to, 0, @as(isize, @intCast(last)))); } /// The answers on offer, in the order shown. pub fn answers(c: *const Confirmation) []const Answer { return answersFor(c.question); } fn answersFor(q: Question) []const Answer { return switch (q) { .paste => &.{ .yes, .no }, .write, .read => &.{ .yes, .yes_for_pane, .no }, }; } /// The selected answer. pub fn current(c: *const Confirmation) Answer { return c.answers()[c.selected]; } /// Selects the answer `by` along from the selected one, going round. pub fn move(c: *Confirmation, by: isize) void { const n: isize = @intCast(c.answers().len); c.selected = @intCast(@mod(@as(isize, @intCast(c.selected)) + by, n)); } /// Whether `new` takes the place of `old`, the question already waiting on /// a pane, `old` then being answered no; otherwise `new` is. An unsafe /// paste is never replaced: the user asked for it, in the pane they were /// looking at. A program's question is, by the newer one, or by a paste. pub fn replaces(old: Question, new: Question) bool { _ = new; return old != .paste; } /// An image a program asks to put on the clipboard, as the confirmation /// shows it. pub const Preview = struct { /// The image's own size, which it is shown no larger than. width: u32, height: u32, /// Its pixels, shrunk to at most `preview_pixels` on a side: straight /// RGBA, owned. rgba: []u8, rgba_width: u32, rgba_height: u32, /// Which preview: a renderer holding an older one's pixels lets them go. generation: u64, }; /// The most pixels on a side a preview keeps, however large the image. pub const preview_pixels = 512; /// The largest image shown, on either side: one larger is only named. pub const max_preview_side = 8192; /// The first PNG among `contents`, decoded and shrunk to show; null when /// there is none, or it is too large, or it does not decode. pub fn imagePreview(gpa: Allocator, contents: []const vt.clipboard.Content, generation: u64) ?Preview { const png = for (contents) |c| { if (std.mem.eql(u8, c.mime, "image/png")) break c.data; } else return null; const size = pngSize(png) orelse return null; if (size.w == 0 or size.h == 0 or size.w > max_preview_side or size.h > max_preview_side) return null; const decode = vt.sys.decode_png orelse return null; const image = decode(gpa, png) catch return null; defer gpa.free(image.data); if (image.data.len != @as(usize, image.width) * image.height * 4) return null; const small = shrink(gpa, image.data, image.width, image.height, preview_pixels) catch return null; return .{ .width = image.width, .height = image.height, .rgba = small.rgba, .rgba_width = small.width, .rgba_height = small.height, .generation = generation, }; } /// A PNG's size, from its header, without decoding it. pub fn pngSize(png: []const u8) ?struct { w: u32, h: u32 } { if (png.len < 24 or !std.mem.eql(u8, png[0..8], "\x89PNG\r\n\x1a\n") or !std.mem.eql(u8, png[12..16], "IHDR")) return null; return .{ .w = std.mem.readInt(u32, png[16..20], .big), .h = std.mem.readInt(u32, png[20..24], .big) }; } /// `w` by `h` fitted into `max_w` by `max_h`, its shape kept, and never /// made larger. pub fn fit(w: u32, h: u32, max_w: u32, max_h: u32) struct { w: u32, h: u32 } { if (w == 0 or h == 0) return .{ .w = 0, .h = 0 }; const k = @min(1.0, @min(@as(f64, @floatFromInt(max_w)) / @as(f64, @floatFromInt(w)), @as(f64, @floatFromInt(max_h)) / @as(f64, @floatFromInt(h)))); return .{ .w = @max(1, @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(w)) * k)))), .h = @max(1, @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(h)) * k)))), }; } /// Straight RGBA `w` by `h` shrunk to fit `max` on a side, each pixel the /// average of those it covers, weighted by their alpha so that the colour /// of a transparent one does not bleed into its neighbours. A copy when it /// fits already. pub fn shrink(gpa: Allocator, rgba: []const u8, w: u32, h: u32, max: u32) Allocator.Error!struct { rgba: []u8, width: u32, height: u32 } { const to = fit(w, h, max, max); const out = try gpa.alloc(u8, @as(usize, to.w) * to.h * 4); if (to.w == w and to.h == h) { @memcpy(out, rgba); return .{ .rgba = out, .width = w, .height = h }; } for (0..to.h) |y| { const y0 = y * h / to.h; const y1 = @max(y0 + 1, (y + 1) * h / to.h); for (0..to.w) |x| { const x0 = x * w / to.w; const x1 = @max(x0 + 1, (x + 1) * w / to.w); var sum: [4]u64 = @splat(0); for (y0..y1) |sy| for (x0..x1) |sx| { const px = rgba[(sy * w + sx) * 4 ..][0..4]; const a: u64 = px[3]; sum[0] += px[0] * a; sum[1] += px[1] * a; sum[2] += px[2] * a; sum[3] += a; }; const n: u64 = (y1 - y0) * (x1 - x0); const dst = out[(y * to.w + x) * 4 ..][0..4]; dst[3] = @intCast(sum[3] / n); for (0..3) |i| dst[i] = if (sum[3] == 0) 0 else @intCast(sum[i] / sum[3]); } } return .{ .rgba = out, .width = to.w, .height = to.h }; } /// Whether pasting `text` would run it as it arrives: it has a line break /// in it, which reaches the program as Enter, and the program has not /// turned on bracketed paste to be told where a paste begins and ends. /// Nothing else counts: ESC and the control characters a shell acts on /// are made spaces as the text is sent, so a paste cannot end bracketed /// paste early, or send ^C. pub fn unsafePaste(text: []const u8, bracketed: bool) bool { if (bracketed) return false; return std.mem.findAny(u8, text, "\r\n") != null; } /// The most cells wide a confirmation's text is laid out. pub const max_text_cells = 120; /// One row of a confirmation's text, as it is shown: its code points, and /// for each whether it is one of harrier's marks rather than the text — /// ⏎ at a line break, ↪ before a wrapped line's next row, a control /// character's picture, or � — which are dim; and the cell each is in. pub const Row = struct { cps: []const u21, marks: []const bool, cols: []const u32, /// Where in the text the next row starts. end: usize, /// Whether the next row goes on with this one's line. wraps: bool, /// The cells it takes. width: u32, }; /// The most code points one row holds, however many a grapheme has. pub const max_row_cps = 4 * max_text_cells; pub const RowBuffers = struct { cps: [max_row_cps]u21 = undefined, marks: [max_row_cps]bool = undefined, cols: [max_row_cps]u32 = undefined, }; /// The row of `text` that starts at `start`, at most `cells` wide, in /// `bufs`; `continued` when it goes on with the row before's line. A line /// break ends a row, as Enter: `\n`, `\r\n` as one, and a lone `\r`. The /// last cell is kept for the ⏎ that marks one, so that a full line and /// its break fit. A tab goes to the next stop of eight cells, a row /// otherwise breaks between graphemes, and no row is empty unless the /// text is. pub fn row(text: []const u8, start: usize, continued: bool, cells: u32, bufs: *RowBuffers) Row { const limit = @max(cells, 2) - 1; var n: usize = 0; var used: u32 = 0; const B = struct { fn put(b: *RowBuffers, at: *usize, cp: u21, mark: bool, col: u32) void { b.cps[at.*] = cp; b.marks[at.*] = mark; b.cols[at.*] = col; at.* += 1; } }; if (continued) { B.put(bufs, &n, 0x21aa, true, 0); used = 1; } // A tab's stops are counted from the line's text, not from ↪. const lead = used; var i = start; while (i < text.len) { const c = text[i]; if (c == '\n' or c == '\r') { B.put(bufs, &n, 0x23ce, true, used); used += 1; const len: usize = if (c == '\r' and i + 1 < text.len and text[i + 1] == '\n') 2 else 1; return done(bufs, n, i + len, false, used); } // Something shown as one cell of its own: a control character's // picture, or � for what is not to be shown. const single: ?struct { cp: u21, len: usize } = switch (c) { '\t' => null, 0...8, 11, 12, 14...31 => .{ .cp = 0x2400 + @as(u21, c), .len = 1 }, 127 => .{ .cp = 0x2421, .len = 1 }, else => if (c < 0x80) null else decoded: { const len = std.unicode.utf8ByteSequenceLength(c) catch break :decoded .{ .cp = 0xfffd, .len = 1 }; if (i + len > text.len) break :decoded .{ .cp = 0xfffd, .len = 1 }; const cp = std.unicode.utf8Decode(text[i..][0..len]) catch break :decoded .{ .cp = 0xfffd, .len = 1 }; break :decoded if (cp >= 0x80 and cp <= 0x9f) .{ .cp = 0xfffd, .len = len } else null; }, }; if (single) |s| { if (used + 1 > limit and n > lead) return done(bufs, n, i, true, used); B.put(bufs, &n, s.cp, true, used); used += 1; i += s.len; continue; } if (c == '\t') { const to = 8 - (used - lead) % 8; if (used + to > limit and n > lead) return done(bufs, n, i, true, used); for (0..to) |k| { if (n == bufs.cps.len) break; B.put(bufs, &n, ' ', false, used + @as(u32, @intCast(k))); } used += to; i += 1; continue; } // A grapheme: decoded far enough to see where it ends, up to // something that cannot be part of one. var cps: [32]u21 = undefined; var ends: [32]usize = undefined; var k: usize = 0; var j = i; while (k < cps.len and j < text.len) { const b = text[j]; if (b < 0x20 or b == 0x7f) break; const len = std.unicode.utf8ByteSequenceLength(b) catch break; if (j + len > text.len) break; const cp = std.unicode.utf8Decode(text[j..][0..len]) catch break; if (k > 0 and cp >= 0x80 and cp <= 0x9f) break; cps[k] = cp; j += len; ends[k] = j; k += 1; // Plain ASCII followed by more of it ends where it is. if (b < 0x80 and (j == text.len or text[j] < 0x80)) break; } const g = vt.unicode.graphemeWidth(u21, cps[0..k]); const glen = @max(g.len, 1); const width: u32 = if (g.width == 2) 2 else 1; if ((used + width > limit or n + glen > bufs.cps.len) and n > lead) return done(bufs, n, i, true, used); for (cps[0..glen]) |cp| B.put(bufs, &n, cp, false, used); used += width; i = ends[glen - 1]; } return done(bufs, n, text.len, false, used); } fn done(bufs: *RowBuffers, n: usize, end: usize, wraps: bool, width: u32) Row { return .{ .cps = bufs.cps[0..n], .marks = bufs.marks[0..n], .cols = bufs.cols[0..n], .end = end, .wraps = wraps, .width = width }; } /// A confirmation's text laid out in rows: where each starts, for drawing /// those shown and scrolling through all of them. pub const Layout = struct { /// The width it was laid out at, and the text: laid out again when /// either changes. cells: u32 = 0, text: []const u8 = &.{}, starts: std.ArrayList(Start) = .empty, /// The widest row. widest: u32 = 0, pub const Start = struct { at: usize, continued: bool }; pub fn deinit(l: *Layout, gpa: Allocator) void { l.starts.deinit(gpa); l.* = .{}; } /// `text` in rows `cells` wide, unless it is already. pub fn update(l: *Layout, gpa: Allocator, text: []const u8, cells: u32) Allocator.Error!void { if (l.cells == cells and l.text.ptr == text.ptr and l.text.len == text.len) return; l.starts.clearRetainingCapacity(); l.widest = 0; l.cells = 0; var bufs: RowBuffers = .{}; var at: usize = 0; var continued = false; while (at < text.len) { try l.starts.append(gpa, .{ .at = at, .continued = continued }); const r = row(text, at, continued, cells, &bufs); l.widest = @max(l.widest, r.width); // Nothing is left behind, whatever the text. at = @max(r.end, at + 1); continued = r.wraps; } l.cells = cells; l.text = text; } /// Row `i`, in `bufs`. pub fn get(l: *const Layout, i: usize, bufs: *RowBuffers) Row { const s = l.starts.items[i]; return row(l.text, s.at, s.continued, l.cells, bufs); } pub fn len(l: *const Layout) usize { return l.starts.items.len; } }; /// Whether a confirmation names the Clipboard types `mimes`: only when one /// is not text, since text alone is shown as text. pub fn namesTypes(mimes: anytype) bool { for (mimes) |m| if (!vt.clipboard.isTextMime(m)) return true; return false; } /// `n` bytes as a confirmation shows a size, in `buf`: bytes under a KiB, /// and otherwise KiB, MiB or GiB to one decimal place. pub fn formatSize(n: usize, buf: []u8) []const u8 { const units = [_][]const u8{ "KiB", "MiB", "GiB" }; if (n < 1024) return std.fmt.bufPrint(buf, "{d} B", .{n}) catch buf[0..0]; var size: f64 = @floatFromInt(n); var unit: usize = 0; size /= 1024; while (size >= 1024 and unit + 1 < units.len) : (unit += 1) size /= 1024; return std.fmt.bufPrint(buf, "{d:.1} {s}", .{ size, units[unit] }) catch buf[0..0]; } const testing = std.testing; test "only a question with something other than text names its types" { try testing.expect(!namesTypes([_][]const u8{ "text/plain", "UTF8_STRING" })); try testing.expect(namesTypes([_][]const u8{ "text/plain", "image/png" })); try testing.expect(!namesTypes([_][]const u8{})); } test "sizes are shown in bytes, then KiB, MiB and GiB" { var buf: [32]u8 = undefined; try testing.expectEqualStrings("0 B", formatSize(0, &buf)); try testing.expectEqualStrings("1023 B", formatSize(1023, &buf)); try testing.expectEqualStrings("1.0 KiB", formatSize(1024, &buf)); try testing.expectEqualStrings("1.2 MiB", formatSize(1258291, &buf)); try testing.expectEqualStrings("64.0 MiB", formatSize(64 * 1024 * 1024, &buf)); try testing.expectEqualStrings("3.0 GiB", formatSize(3 * 1024 * 1024 * 1024, &buf)); } test "a paste is unsafe when it has a line break and the program has no bracketed paste" { try testing.expect(unsafePaste("ls\nrm -rf ~\n", false)); try testing.expect(unsafePaste("one\r", false)); try testing.expect(!unsafePaste("one line", false)); // Bracketed, the program is told it is a paste, and runs nothing. try testing.expect(!unsafePaste("ls\nrm -rf ~\n", true)); // ESC is made a space as it is sent, so this ends nothing early. try testing.expect(!unsafePaste("x\x1b[201~y", true)); try testing.expect(!unsafePaste("", false)); } test "a pane's waiting paste is never replaced, a program's question is" { const paste: Question = .{ .paste = .{ .text = &.{}, .from = .clipboard } }; const read: Question = .{ .read = .{ .clipboard = .standard, .reply = undefined } }; const write: Question = .{ .write = .{ .contents = &.{}, .clipboard = .standard, .reply = .{} } }; try testing.expect(!replaces(paste, read)); try testing.expect(!replaces(paste, paste)); try testing.expect(replaces(read, read)); try testing.expect(replaces(read, write)); try testing.expect(replaces(write, paste)); } test "the answer that does nothing is selected to begin with" { var c = init(.{ .read = .{ .clipboard = .standard, .reply = undefined } }); try testing.expectEqual(Answer.no, c.current()); c.move(1); try testing.expectEqual(Answer.yes, c.current()); c.move(-1); try testing.expectEqual(Answer.no, c.current()); const p = init(.{ .paste = .{ .text = &.{}, .from = .drop } }); try testing.expectEqual(Answer.no, p.current()); try testing.expectEqual(@as(usize, 2), p.answers().len); } /// Row `i` of `text` laid out `cells` wide, as text: each mark in /// brackets, so that what is harrier's and what is the text's can be told /// apart. fn shown(text: []const u8, cells: u32) ![]const u8 { const S = struct { var out: [4096]u8 = undefined; }; var layout: Layout = .{}; defer layout.deinit(testing.allocator); try layout.update(testing.allocator, text, cells); var w: std.Io.Writer = .fixed(&S.out); var bufs: RowBuffers = .{}; for (0..layout.len()) |i| { if (i > 0) try w.writeAll("|"); const r = layout.get(i, &bufs); for (r.cps, r.marks) |cp, mark| { if (mark) try w.writeAll("["); try w.print("{u}", .{cp}); if (mark) try w.writeAll("]"); } } return w.buffered(); } test "each line is a row, its break marked, and a lone CR is one" { try testing.expectEqualStrings("ls[⏎]|rm -rf ~[⏎]", try shown("ls\r\nrm -rf ~\n", 40)); try testing.expectEqualStrings("a[⏎]|b", try shown("a\rb", 40)); try testing.expectEqualStrings("[⏎]|[⏎]|x", try shown("\n\nx", 40)); try testing.expectEqualStrings("", try shown("", 40)); } test "a long line wraps, each row after the first marked" { // Ten cells: nine of text, and one kept for a break. try testing.expectEqualStrings("abcdefghi|[↪]jklmnopq|[↪]rs[⏎]|t", try shown("abcdefghijklmnopqrs\nt", 10)); // A line that fills its row has its break there too. try testing.expectEqualStrings("abcdefghi[⏎]|j", try shown("abcdefghi\nj", 10)); } test "no grapheme is cut, and a wide one takes two cells" { try testing.expectEqualStrings("e\u{301}e\u{301}|[↪]e\u{301}", try shown("e\u{301}e\u{301}e\u{301}", 3)); var bufs: RowBuffers = .{}; const r = row("\u{4e2d}\u{6587}x", 0, false, 10, &bufs); try testing.expectEqual(@as(u32, 5), r.width); try testing.expectEqualSlices(u32, &.{ 0, 2, 4 }, r.cols); } test "tabs go to stops of eight, counted from the line's text" { var bufs: RowBuffers = .{}; try testing.expectEqual(@as(u32, 9), row("ab\tc", 0, false, 40, &bufs).width); try testing.expectEqual(@as(u32, 10), row("ab\tc", 0, true, 40, &bufs).width); } test "control characters are shown as their pictures, and what is not UTF-8 as one" { try testing.expectEqualStrings("[␛][A[␇][␀][␡]", try shown("\x1b[A\x07\x00\x7f", 40)); try testing.expectEqualStrings("a[\u{fffd}]b[\u{fffd}]c", try shown("a\xffb\u{9b}c", 40)); try testing.expectEqualStrings("[\u{fffd}][\u{fffd}]", try shown("\xe2\x82", 40)); } test "a layout is made again only for other text or another width" { var layout: Layout = .{}; defer layout.deinit(testing.allocator); const text = "abcdefghijklmnopqrstuvwxyz"; try layout.update(testing.allocator, text, 10); try testing.expectEqual(@as(usize, 4), layout.len()); try testing.expectEqual(@as(u32, 9), layout.widest); try layout.update(testing.allocator, text, 40); try testing.expectEqual(@as(usize, 1), layout.len()); try testing.expectEqual(@as(u32, 26), layout.widest); } test fit { try testing.expectEqual(@as(u32, 100), fit(100, 50, 640, 160).w); const wide = fit(2000, 1000, 640, 160); try testing.expectEqual(@as(u32, 320), wide.w); try testing.expectEqual(@as(u32, 160), wide.h); const thin = fit(1, 5000, 640, 160); try testing.expectEqual(@as(u32, 1), thin.w); try testing.expectEqual(@as(u32, 160), thin.h); } test shrink { // 4x2: a red pair, a transparent green pair; below them, the same. // Shrunk to 2x1, red stays red, and the transparent one stays // transparent rather than going dark. const red = [4]u8{ 255, 0, 0, 255 }; const clear = [4]u8{ 0, 255, 0, 0 }; const px = red ++ red ++ clear ++ clear ++ red ++ red ++ clear ++ clear; const s = try shrink(testing.allocator, &px, 4, 2, 2); defer testing.allocator.free(s.rgba); try testing.expectEqual(@as(u32, 2), s.width); try testing.expectEqual(@as(u32, 1), s.height); try testing.expectEqualSlices(u8, &(red ++ [4]u8{ 0, 0, 0, 0 }), s.rgba); // Half red, half transparent: half as opaque, and still red. const mixed = red ++ clear; const m = try shrink(testing.allocator, &mixed, 2, 1, 1); defer testing.allocator.free(m.rgba); try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 127 }, m.rgba); // Small enough already: a copy. const same = try shrink(testing.allocator, &mixed, 2, 1, 512); defer testing.allocator.free(same.rgba); try testing.expectEqualSlices(u8, &mixed, same.rgba); } test pngSize { const header = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x01\x00\x00\x00\x00\x40"; const size = pngSize(header).?; try testing.expectEqual(@as(u32, 256), size.w); try testing.expectEqual(@as(u32, 64), size.h); try testing.expect(pngSize("GIF89a, not a PNG at all") == null); } test "a write with no PNG has no preview" { const contents = [_]vt.clipboard.Content{ .{ .mime = "text/plain", .data = "hi" }, .{ .mime = "image/png", .data = "not a png" } }; try testing.expect(imagePreview(testing.allocator, &contents, 1) == null); }