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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A toast: a line over a pane saying what has just happened in it, such//! as its program copying to the clipboard, which asks nothing and goes//! away on its own.//!//! A pane shows at most one; a newer one takes its place. What it says is//! here, and where and for how long it is shown are the window's.
const std = @import("std");const vt = @import("ghostty-vt");const l10n = @import("l10n.zig");const Confirmation = @import("Confirmation.zig");
const Toast = @This();
text_buf: [256]u8 = undefined,text_len: usize = 0,/// It is gone from then on.until: std.Io.Timestamp,
/// What happened to the clipboard, and who did it.pub const Event = struct { /// The user, who is not named, or the program in the pane, by name, or /// null for one that cannot be named. who: union(enum) { user, program: ?[]const u8 }, what: enum { copied, read, failed }, clipboard: Confirmation.Clipboard, /// What was copied: every type of it. contents: []const vt.clipboard.Content = &.{},
/// Whether the user may turn it off: anything but a failure, which /// would otherwise go unnoticed. pub fn optional(e: Event) bool { return e.what != .failed; }};
pub fn init(e: Event, until: std.Io.Timestamp) Toast { var t: Toast = .{ .until = until }; var w: std.Io.Writer = .fixed(&t.text_buf); say(&w, e) catch {}; t.text_len = w.end; return t;}
pub fn text(t: *const Toast) []const u8 { return t.text_buf[0..t.text_len];}
/// What a toast says of `e`.fn say(w: *std.Io.Writer, e: Event) std.Io.Writer.Error!void { const which = l10n.str("which", @tagName(e.clipboard)); if (e.what == .failed) return l10n.write(w, "toast-failed", &.{which}); if (e.what == .copied and emptied(e.contents)) return l10n.write(w, "toast-cleared", &.{which}); var amount_buf: [128]u8 = undefined; var aw: std.Io.Writer = .fixed(&amount_buf); if (e.what == .copied) amount(&aw, e.contents) catch {}; const amount_arg = l10n.str("amount", aw.buffered()); switch (e.who) { .user => try l10n.write(w, "toast-copied", &.{ which, amount_arg }), .program => |name| { const id = switch (e.what) { .copied => if (name == null) "toast-program-copied-unnamed" else "toast-program-copied", .read => if (name == null) "toast-program-read-unnamed" else "toast-program-read", .failed => unreachable, }; try l10n.write(w, id, &.{ l10n.str("program", name orelse ""), which, amount_arg }); }, }}
/// Whether `contents` leaves the clipboard empty: a program clearing it.fn emptied(contents: []const vt.clipboard.Content) bool { for (contents) |c| if (c.data.len > 0) return false; return true;}
/// How much `contents` is: its text's lines, or characters when it is one/// line; and when it is something other than text, each type and its/// size, as a confirmation lists them.fn amount(w: *std.Io.Writer, contents: []const vt.clipboard.Content) std.Io.Writer.Error!void { const mimes = mimes: { var buf: [vt.kitty.clipboard.max_write_mimes][]const u8 = undefined; const n = @min(contents.len, buf.len); for (contents[0..n], buf[0..n]) |c, *b| b.* = c.mime; break :mimes buf[0..n]; }; if (Confirmation.namesTypes(mimes)) { var first = true; for (contents) |c| { if (vt.clipboard.isTextMime(c.mime)) continue; if (!first) try w.writeAll(" · "); first = false; var size_buf: [32]u8 = undefined; try l10n.write(w, "confirm-type-size", &.{ l10n.str("type", c.mime), l10n.str("size", Confirmation.formatSize(c.data.len, &size_buf)), }); } return; } const t = for (contents) |c| { if (vt.clipboard.isTextMime(c.mime)) break c.data; } else ""; const c = count(t); if (c.lines > 1) try l10n.write(w, "toast-lines", &.{l10n.num("count", c.lines)}) else try l10n.write(w, "toast-characters", &.{l10n.num("count", c.graphemes)});}
pub const Count = struct { lines: usize, graphemes: usize };
/// How many lines `t` has, a last one without a line break counted,/// and how many graphemes there are in it other than the line breaks. A/// byte that is not UTF-8 counts as one.pub fn count(t: []const u8) Count { var lines: usize = 0; var graphemes: usize = 0; // Decoded a piece at a time, without cutting a grapheme off at the end // of one: a piece is taken only as far as its last line break, or // left short of its end by more than any grapheme anyone writes. var cps: [512]u21 = undefined; const slack = 64; var n: usize = 0; var i: usize = 0; var last_break = true; while (true) { while (n < cps.len and i < t.len) { const len = std.unicode.utf8ByteSequenceLength(t[i]) catch 1; const cp = if (i + len <= t.len) std.unicode.utf8Decode(t[i..][0..len]) catch null else null; if (cp) |x| { cps[n] = x; i += len; } else { cps[n] = 0xfffd; i += 1; } n += 1; } const end = i == t.len; const upto = if (end) n else n - slack; var at: usize = 0; while (at < upto) { switch (cps[at]) { '\n' => { lines += 1; last_break = true; at += 1; continue; }, '\r' => { lines += 1; last_break = true; at += if (at + 1 < n and cps[at + 1] == '\n') 2 else 1; continue; }, else => {}, } // Stops at a line break, which no grapheme joins. var stop = at + 1; while (stop < n and cps[stop] != '\n' and cps[stop] != '\r') stop += 1; const g = vt.unicode.graphemeWidth(u21, cps[at..stop]); graphemes += 1; last_break = false; at += @max(g.len, 1); } if (end) break; std.mem.copyForwards(u21, cps[0 .. n - at], cps[at..n]); n -= at; } if (!last_break) lines += 1; return .{ .lines = lines, .graphemes = graphemes };}
// ---------------------------------------------------------------------------
const testing = std.testing;
fn says(e: Event) []const u8 { const S = struct { var t: Toast = undefined; }; S.t = .init(e, .zero); return S.t.text();}
fn plain(t: []const u8) [1]vt.clipboard.Content { return .{.{ .mime = "text/plain", .data = t }};}
test "lines are counted, and a last one without a line break" { try testing.expectEqual(Count{ .lines = 0, .graphemes = 0 }, count("")); try testing.expectEqual(Count{ .lines = 1, .graphemes = 3 }, count("abc")); try testing.expectEqual(Count{ .lines = 1, .graphemes = 3 }, count("abc\n")); try testing.expectEqual(Count{ .lines = 2, .graphemes = 2 }, count("a\r\nb")); try testing.expectEqual(Count{ .lines = 3, .graphemes = 2 }, count("a\n\nb\n"));}
test "characters are graphemes, and a byte that is not UTF-8 is one" { // e and a combining acute, a flag, and ➡ asking to be an emoji. try testing.expectEqual(@as(usize, 3), count("e\u{301}\u{1F1EF}\u{1F1F5}\u{27A1}\u{FE0F}").graphemes); try testing.expectEqual(@as(usize, 3), count("a\xffb").graphemes);}
test "a grapheme across the end of a piece of decoding is one" { var buf: [2000]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); for (0..300) |_| try w.writeAll("e\u{301}"); try testing.expectEqual(Count{ .lines = 1, .graphemes = 300 }, count(w.buffered()));}
test "the user's own copy names nobody" { try testing.expectEqualStrings("Copied 42 characters", says(.{ .who = .user, .what = .copied, .clipboard = .standard, .contents = &plain("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") })); try testing.expectEqualStrings("Copied 1 character", says(.{ .who = .user, .what = .copied, .clipboard = .standard, .contents = &plain("a") })); try testing.expectEqualStrings("Copied 3 lines to the primary selection", says(.{ .who = .user, .what = .copied, .clipboard = .primary, .contents = &plain("a\nb\nc\n") }));}
test "a program is named, and one that cannot be is the program" { try testing.expectEqualStrings("ssh copied 3 lines to the clipboard", says(.{ .who = .{ .program = "ssh" }, .what = .copied, .clipboard = .standard, .contents = &plain("a\nb\nc") })); try testing.expectEqualStrings("The program copied 2 characters to the primary selection", says(.{ .who = .{ .program = null }, .what = .copied, .clipboard = .primary, .contents = &plain("ab") })); try testing.expectEqualStrings("nvim read the primary selection", says(.{ .who = .{ .program = "nvim" }, .what = .read, .clipboard = .primary })); try testing.expectEqualStrings("The program read the clipboard", says(.{ .who = .{ .program = null }, .what = .read, .clipboard = .standard }));}
test "something other than text is its types and sizes" { const contents = [_]vt.clipboard.Content{ .{ .mime = "text/plain", .data = "a picture" }, .{ .mime = "image/png", .data = &(@as([2048]u8, @splat(0))) }, }; try testing.expectEqualStrings("tmux copied image/png (2.0 KiB) to the clipboard", says(.{ .who = .{ .program = "tmux" }, .what = .copied, .clipboard = .standard, .contents = &contents }));}
test "an empty write clears, and a failure says so" { try testing.expectEqualStrings("Cleared the clipboard", says(.{ .who = .{ .program = "nvim" }, .what = .copied, .clipboard = .standard, .contents = &plain("") })); try testing.expectEqualStrings("Cleared the primary selection", says(.{ .who = .{ .program = "nvim" }, .what = .copied, .clipboard = .primary })); try testing.expectEqualStrings("Could not copy to the clipboard", says(.{ .who = .{ .program = "nvim" }, .what = .failed, .clipboard = .standard, .contents = &plain("x") })); try testing.expect(!(Event{ .who = .user, .what = .failed, .clipboard = .standard }).optional());}