okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfeat: multiple images, collapsed pastes, and an attachment strip in the prompt6e82811

main
5.4 KiB164 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"bytes"
6	"context"
7	"encoding/base64"
8	"image"
9	"os"
10	"os/exec"
11	"strings"
12	"sync"
13	"sync/atomic"
14	"time"
15
16	"github.com/charmbracelet/x/ansi"
17	"github.com/charmbracelet/x/ansi/kitty"
18)
19
20// A chip's thumbnail area, in cells. Cells are taken to be twice as tall as
21// they are wide.
22const (
23	thumbCols = 12
24	thumbRows = 4
25	// thumbPixels bounds the longer edge of what is sent to the terminal.
26	thumbPixels = 240
27)
28
29// thumbnail is an image the terminal holds under id, shown in cols x rows
30// cells by writing its Unicode placeholders.
31type thumbnail struct {
32	id, cols, rows int
33}
34
35// graphics is how kitty images reach this terminal: supported when it shows
36// them through Unicode placeholders, wrapped when tmux sits in between.
37type graphics struct {
38	supported, wrapped bool
39	// hint explains why a terminal that could show thumbnails does not.
40	hint string
41}
42
43var terminalGraphics = sync.OnceValue(func() graphics {
44	return detectGraphics(os.Getenv, func(args ...string) string {
45		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
46		defer cancel()
47		out, _ := exec.CommandContext(ctx, "tmux", args...).Output()
48		return strings.TrimSpace(string(out))
49	})
50})
51
52// detectGraphics reads the terminal from the environment, and through tmux
53// the terminal of the client attached to it.
54func detectGraphics(getenv func(string) string, tmux func(args ...string) string) graphics {
55	if getenv("TMUX") == "" {
56		return graphics{supported: placeholderTerminal(getenv("TERM"), getenv("TERM_PROGRAM")) || getenv("KITTY_WINDOW_ID") != ""}
57	}
58	if !placeholderTerminal(tmux("display-message", "-p", "#{client_termname}"), "") {
59		return graphics{}
60	}
61	switch tmux("show-options", "-gv", "allow-passthrough") {
62	case "on", "all":
63		return graphics{supported: true, wrapped: true}
64	}
65	return graphics{hint: "image thumbnails need tmux's allow-passthrough: set -g allow-passthrough on"}
66}
67
68// placeholderTerminal reports a terminal known to draw kitty images through
69// Unicode placeholders.
70func placeholderTerminal(term, program string) bool {
71	return strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") || program == "ghostty"
72}
73
74// thumbnailIDs numbers transmitted images 16..255: the id travels as a
75// placeholder's 256-colour foreground, and the renderer may rewrite the first
76// sixteen as basic colours. A reused id replaces a long-gone image.
77var thumbnailIDs atomic.Uint32
78
79// transmitThumbnail decodes image, scales it down, and returns the escape
80// sequence that hands it to the terminal, with where it will be shown. It
81// returns false for a terminal without placeholders or an image it cannot
82// decode (WebP).
83func transmitThumbnail(g graphics, attachment daemon.ImageAttachment) (thumbnail, string, bool) {
84	if !g.supported {
85		return thumbnail{}, "", false
86	}
87	data, err := base64.StdEncoding.DecodeString(attachment.Data)
88	if err != nil {
89		return thumbnail{}, "", false
90	}
91	decoded, _, err := image.Decode(bytes.NewReader(data))
92	if err != nil {
93		return thumbnail{}, "", false
94	}
95	cols, rows := thumbnailCells(decoded.Bounds().Dx(), decoded.Bounds().Dy())
96	thumb := thumbnail{id: 16 + int(thumbnailIDs.Add(1)-1)%240, cols: cols, rows: rows}
97	options := &kitty.Options{
98		Action: kitty.TransmitAndPut, Transmission: kitty.Direct, Format: kitty.PNG, Quiet: 2,
99		ID: thumb.id, VirtualPlacement: true, Columns: cols, Rows: rows, Chunk: true,
100	}
101	if g.wrapped {
102		options.ChunkFormatter = ansi.TmuxPassthrough
103	}
104	var seq strings.Builder
105	if err := kitty.EncodeGraphics(&seq, shrink(decoded, thumbPixels), options); err != nil {
106		return thumbnail{}, "", false
107	}
108	return thumb, seq.String(), true
109}
110
111// thumbnailCells fits a width x height pixel image into the thumbnail area.
112func thumbnailCells(width, height int) (int, int) {
113	if width <= 0 || height <= 0 {
114		return thumbCols, thumbRows
115	}
116	// a cell is about twice as tall as wide: rows = cols * height / width / 2
117	rows := max(1, (thumbCols*height+width)/(2*width))
118	if rows <= thumbRows {
119		return thumbCols, rows
120	}
121	return max(1, min(thumbCols, (2*thumbRows*width+height/2)/height)), thumbRows
122}
123
124// shrink scales img so its longer edge is at most edge pixels, sampling the
125// nearest source pixel.
126func shrink(img image.Image, edge int) image.Image {
127	bounds := img.Bounds()
128	width, height := bounds.Dx(), bounds.Dy()
129	if width <= edge && height <= edge {
130		return img
131	}
132	scaledW, scaledH := edge, max(1, height*edge/width)
133	if height > width {
134		scaledW, scaledH = max(1, width*edge/height), edge
135	}
136	out := image.NewRGBA(image.Rect(0, 0, scaledW, scaledH))
137	for y := range scaledH {
138		for x := range scaledW {
139			out.Set(x, y, img.At(bounds.Min.X+x*width/scaledW, bounds.Min.Y+y*height/scaledH))
140		}
141	}
142	return out
143}
144
145// placeholderRows are the cells that draw thumb: each one the placeholder
146// character with its row and column as diacritics. Their foreground names
147// the image rather than a colour, so it comes from the id, not the theme.
148func placeholderRows(thumb thumbnail) []string {
149	image := ansi.Style{}.ForegroundColor(ansi.IndexedColor(thumb.id)).String()
150	reset := ansi.Style{}.DefaultForegroundColor().String()
151	rows := make([]string, thumb.rows)
152	for r := range rows {
153		var row strings.Builder
154		row.WriteString(image)
155		for c := range thumb.cols {
156			row.WriteRune(kitty.Placeholder)
157			row.WriteRune(kitty.Diacritic(r))
158			row.WriteRune(kitty.Diacritic(c))
159		}
160		row.WriteString(reset)
161		rows[r] = row.String()
162	}
163	return rows
164}