okami.mom/ultraviolet

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

Ayman Bagabasrefactor(examples): move examples to advanced directoryf72cc87

upstream
10.4 KiB418 linesraw
1package main
2
3import (
4	"bytes"
5	"context"
6	"encoding/base64"
7	"flag"
8	"fmt"
9	"image"
10	"image/color"
11	"io"
12	"log"
13	"os"
14	"slices"
15	"strings"
16	"time"
17
18	_ "image/jpeg" // Register JPEG format
19
20	uv "github.com/charmbracelet/ultraviolet"
21	"github.com/charmbracelet/ultraviolet/screen"
22	"github.com/charmbracelet/x/ansi"
23	"github.com/charmbracelet/x/ansi/iterm2"
24	"github.com/charmbracelet/x/ansi/kitty"
25	"github.com/charmbracelet/x/ansi/sixel"
26	"github.com/charmbracelet/x/mosaic"
27)
28
29type imageEncoding uint8
30
31const (
32	blocksEncoding imageEncoding = iota + 1
33	sixelEncoding
34	itermEncoding
35	kittyEncoding
36
37	unknownEncoding = 0
38)
39
40func (e imageEncoding) String() string {
41	switch e {
42	case blocksEncoding:
43		return "blocks"
44	case sixelEncoding:
45		return "sixel"
46	case itermEncoding:
47		return "iterm"
48	case kittyEncoding:
49		return "kitty"
50	default:
51		return "unknown"
52	}
53}
54
55var desiredEnc int
56
57func init() {
58	flag.IntVar(&desiredEnc, "encoding", int(unknownEncoding), "image encoding")
59
60	f, err := os.OpenFile("image.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
61	if err != nil {
62		log.Fatalf("failed to open log file: %v", err)
63	}
64	log.SetFlags(log.LstdFlags | log.Lshortfile)
65	log.SetOutput(f)
66}
67
68func main() {
69	flag.Parse()
70
71	t := uv.DefaultTerminal()
72	if err := t.Start(); err != nil {
73		log.Fatalf("failed to start terminal: %v", err)
74	}
75
76	defer t.Stop()
77
78	scr := t.Screen()
79
80	// Use altscreen buffer.
81	scr.EnterAltScreen() //nolint:errcheck
82
83	// Enable mouse support.
84	scr.SetMouseMode(uv.MouseModeClick)
85
86	// Get image info.
87	charmImgFile, err := os.Open("./charm.jpg")
88	if err != nil {
89		log.Fatalf("failed to open image file: %v", err)
90	}
91
92	defer charmImgFile.Close() //nolint:errcheck
93	charmImgStat, err := charmImgFile.Stat()
94	if err != nil {
95		log.Fatalf("failed to stat image file: %v", err)
96	}
97
98	charmImgFileSize := charmImgStat.Size()
99
100	var charmImgBuf bytes.Buffer
101	var charmImgB64 []byte
102	imgTee := io.TeeReader(charmImgFile, &charmImgBuf)
103	charmImg, _, err := image.Decode(imgTee)
104	if err != nil {
105		log.Fatalf("failed to decode image: %v", err)
106	}
107
108	charmImgArea := charmImg.Bounds()
109
110	// Image related variables.
111	var (
112		winSize uv.WindowSizeEvent
113		pixSize uv.PixelSizeEvent
114		imgEnc  = blocksEncoding
115	)
116	if desiredEnc > 0 {
117		imgEnc = imageEncoding(desiredEnc)
118	}
119
120	scr.Resize(winSize.Width, winSize.Height) //nolint:errcheck
121
122	upgradeEnc := func(enc imageEncoding) {
123		if desiredEnc == unknownEncoding {
124			if enc > imgEnc {
125				imgEnc = enc
126			}
127		}
128	}
129
130	// Check environment variables for supported encodings.
131	var (
132		termType    = os.Getenv("TERM")
133		termProg    string
134		lcTerm      string
135		termVersion string
136		ok          bool
137	)
138	if termProg, ok = os.LookupEnv("TERM_PROGRAM"); ok {
139		if strings.Contains(termProg, "iTerm") ||
140			strings.Contains(termProg, "WezTerm") ||
141			strings.Contains(termProg, "mintty") ||
142			strings.Contains(termProg, "vscode") ||
143			strings.Contains(termProg, "Tabby") ||
144			strings.Contains(termProg, "Hyper") ||
145			strings.Contains(termProg, "rio") {
146			upgradeEnc(itermEncoding)
147		}
148		if lcTerm, ok = os.LookupEnv("LC_TERMINAL"); ok {
149			if strings.Contains(lcTerm, "iTerm") {
150				upgradeEnc(itermEncoding)
151			}
152		}
153	}
154
155	// Display image methods.
156	imgCellSize := func() (int, int) {
157		if winSize.Width == 0 || winSize.Height == 0 || pixSize.Width == 0 || pixSize.Height == 0 {
158			return 0, 0
159		}
160
161		cellW, cellH := pixSize.Width/winSize.Width, pixSize.Height/winSize.Height
162		imgW, imgH := charmImgArea.Dx(), charmImgArea.Dy()
163		return imgW / cellW, imgH / cellH
164	}
165
166	var transmitKitty bool
167	var imgCellW, imgCellH int
168	var imgOffsetX, imgOffsetY int
169	imgCellW, imgCellH = imgCellSize()
170	imgOffsetX = winSize.Width/2 - imgCellW/2
171	imgOffsetY = winSize.Height/2 - imgCellH/2
172
173	fillStyle := uv.Style{Fg: ansi.IndexedColor(240)}
174	displayImg := func() {
175		img := charmImg
176		imgArea := uv.Rect(
177			imgOffsetX,
178			imgOffsetY,
179			imgCellW,
180			imgCellH,
181		)
182		if !imgArea.In(winSize.Bounds()) {
183			imgArea = imgArea.Intersect(winSize.Bounds())
184			// TODO: Crop image.
185		}
186
187		log.Printf("image area: %v", imgArea)
188
189		// Clear the screen.
190		screen.Clear(scr)
191		fill := uv.Cell{Content: "/", Width: 1, Style: fillStyle}
192		screen.Fill(scr, &fill)
193
194		// Draw the image on the screen.
195		switch imgEnc {
196		case blocksEncoding:
197			blocks := mosaic.New().Width(imgCellW).Height(imgCellH).Scale(2)
198			ss := uv.NewStyledString(blocks.Render(img))
199			ss.Draw(scr, imgArea)
200
201		case itermEncoding, sixelEncoding:
202			for y := imgArea.Min.Y; y < imgArea.Max.Y; y++ {
203				var content string
204				if y == imgArea.Min.Y {
205					switch imgEnc {
206					case itermEncoding:
207						if charmImgB64 == nil {
208							// Encode the image to base64 for the first time.
209							charmImgB64 = []byte(base64.StdEncoding.EncodeToString(charmImgBuf.Bytes()))
210						}
211						content = ansi.ITerm2(iterm2.File{
212							Name:              "charm.jpg",
213							Width:             iterm2.Cells(imgArea.Dx()),
214							Height:            iterm2.Cells(imgArea.Dy()),
215							Content:           charmImgB64,
216							Inline:            true,
217							IgnoreAspectRatio: true,
218						}) + ansi.CursorPosition(imgArea.Min.X+imgArea.Dx()+1, imgArea.Min.Y+1)
219					case sixelEncoding:
220						var senc sixel.Encoder
221						var buf bytes.Buffer
222						senc.Encode(&buf, img)
223						content = ansi.SixelGraphics(0, 1, 0, buf.Bytes()) +
224							ansi.CursorPosition(imgArea.Min.X+imgArea.Dx()+1, imgArea.Min.Y+1)
225					}
226				} else {
227					content = ansi.CursorForward(imgArea.Dx())
228				}
229
230				scr.SetCell(imgArea.Min.X, y, &uv.Cell{
231					Content: content,
232					Width:   imgArea.Dx(),
233				})
234			}
235
236		case kittyEncoding:
237			const imgId = 31 // random id for kitty graphics
238			if !transmitKitty {
239				var buf bytes.Buffer
240				if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
241					ID:               imgId,
242					Action:           kitty.TransmitAndPut,
243					Transmission:     kitty.Direct,
244					Format:           kitty.RGBA,
245					Size:             int(charmImgFileSize),
246					ImageWidth:       charmImgArea.Dx(),
247					ImageHeight:      charmImgArea.Dy(),
248					Columns:          imgArea.Dx(),
249					Rows:             imgArea.Dy(),
250					VirtualPlacement: true,
251					Quite:            2,
252				}); err != nil {
253					log.Fatalf("failed to encode image for Kitty Graphics: %v", err)
254				}
255
256				io.WriteString(scr, buf.String())
257				transmitKitty = true
258			}
259
260			// Build Kitty graphics unicode place holders
261			var fg color.Color
262			var extra int
263			var r, g, b int
264			extra, r, g, b = imgId>>24&0xff, imgId>>16&0xff, imgId>>8&0xff, imgId&0xff
265
266			if r == 0 && g == 0 {
267				fg = ansi.IndexedColor(b)
268			} else {
269				fg = color.RGBA{
270					R: uint8(r), //nolint:gosec
271					G: uint8(g), //nolint:gosec
272					B: uint8(b), //nolint:gosec
273					A: 0xff,
274				}
275			}
276
277			for y := 0; y < imgArea.Dy(); y++ {
278				// As an optimization, we only write the fg color sequence id, and
279				// column-row data once on the first cell. The terminal will handle
280				// the rest.
281				content := []rune{kitty.Placeholder, kitty.Diacritic(y), kitty.Diacritic(0)}
282				if extra > 0 {
283					content = append(content, kitty.Diacritic(extra))
284				}
285				scr.SetCell(imgArea.Min.X, imgArea.Min.Y+y, &uv.Cell{
286					Style:   uv.Style{Fg: fg},
287					Content: string(content),
288					Width:   1,
289				})
290				for x := 1; x < imgArea.Dx(); x++ {
291					scr.SetCell(imgArea.Min.X+x, imgArea.Min.Y+y, &uv.Cell{
292						Style:   uv.Style{Fg: fg},
293						Content: string(kitty.Placeholder),
294						Width:   1,
295					})
296				}
297			}
298
299		}
300
301		scr.Render() //nolint:errcheck
302		scr.Flush()
303	}
304
305	// Query image encoding support.
306	scr.WriteString(ansi.RequestPrimaryDeviceAttributes)        // Query Sixel support.
307	scr.WriteString(ansi.RequestNameVersion)                    // Query terminal version and name.
308	scr.WriteString(ansi.WindowOp(ansi.RequestWindowSizeWinOp)) // Request window size.
309	// Query Kitty Graphics support using random id=31.
310	scr.WriteString(ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"))
311
312	ctx, cancel := context.WithCancel(context.Background())
313	defer cancel()
314
315	// Listen for input events.
316LOOP:
317	for {
318		select {
319		case <-ctx.Done():
320			break LOOP
321		case ev := <-t.Events():
322			switch ev := ev.(type) {
323			case uv.PixelSizeEvent:
324				// XXX: This is only emitted with traditional Unix systems. On
325				// Windows, we would need to use [ansi.RequestWindowSizeWinOp] to
326				// get the pixel size.
327				pixSize = ev
328				imgCellW, imgCellH = imgCellSize()
329				imgOffsetX = winSize.Width/2 - imgCellW/2
330				imgOffsetY = winSize.Height/2 - imgCellH/2
331				displayImg()
332			case uv.WindowSizeEvent:
333				winSize = ev
334				imgCellW, imgCellH = imgCellSize()
335				imgOffsetX = winSize.Width/2 - imgCellW/2
336				imgOffsetY = winSize.Height/2 - imgCellH/2
337				log.Printf("image cell size: %d x %d", imgCellW, imgCellH)
338				scr.Resize(ev.Width, ev.Height)
339
340				displayImg()
341			case uv.KeyPressEvent:
342				switch {
343				case ev.MatchString("q", "ctrl+c"):
344					cancel() // This will stop the loop
345				case ev.MatchString("up", "k"):
346					imgOffsetY--
347				case ev.MatchString("down", "j"):
348					imgOffsetY++
349				case ev.MatchString("left", "h"):
350					imgOffsetX--
351				case ev.MatchString("right", "l"):
352					imgOffsetX++
353				}
354
355				displayImg()
356			case uv.MouseClickEvent:
357				imgOffsetX = ev.X - (imgCellW / 2)
358				imgOffsetY = ev.Y - (imgCellH / 2)
359
360				displayImg()
361			case uv.PrimaryDeviceAttributesEvent:
362				if slices.Contains(ev, 4) {
363					upgradeEnc(sixelEncoding)
364					displayImg()
365				}
366
367			case uv.TerminalVersionEvent:
368				if strings.Contains(ev.Name, "iTerm") || strings.Contains(ev.Name, "WezTerm") {
369					upgradeEnc(itermEncoding)
370					displayImg()
371				}
372
373			case uv.WindowOpEvent:
374				// Here 4 corresponds to the window size response.
375				if ev.Op == 4 && len(ev.Args) >= 2 {
376					pixSize.Height = ev.Args[0]
377					pixSize.Width = ev.Args[1]
378				}
379			case uv.KittyGraphicsEvent:
380				if strings.Contains(termType, "wezterm") ||
381					strings.Contains(termVersion, "WezTerm") ||
382					strings.Contains(termProg, "WezTerm") {
383					// WezTerm doesn't support Kitty Unicode Graphics
384					break
385				}
386				if ev.Options.ID == 31 {
387					upgradeEnc(kittyEncoding)
388				}
389
390				displayImg()
391			}
392		}
393	}
394
395	ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
396	defer cancel()
397
398	// Disable mouse support.
399	scr.WriteString(ansi.ResetMode(
400		ansi.ModeMouseButtonEvent,
401		ansi.ModeMouseExtSgr,
402	))
403
404	if err := t.Stop(); err != nil {
405		log.Fatalf("failed to shutdown program: %v", err)
406	}
407
408	fmt.Println("image encoding:", imgEnc)
409}
410
411func init() {
412	f, err := os.OpenFile("uv_debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
413	if err != nil {
414		log.Fatalf("failed to open log file: %v", err)
415	}
416
417	log.SetOutput(f)
418}