// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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()); }