Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}