File: main.go

package info (click to toggle)
golang-github-charmbracelet-ultraviolet 0.0~git20251017.d4ace4d-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 872 kB
  • sloc: makefile: 3
file content (430 lines) | stat: -rw-r--r-- 11,095 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"flag"
	"fmt"
	"image"
	"image/color"
	"io"
	"log"
	"os"
	"slices"
	"strings"
	"time"

	_ "image/jpeg" // Register JPEG format

	uv "github.com/charmbracelet/ultraviolet"
	"github.com/charmbracelet/x/ansi"
	"github.com/charmbracelet/x/ansi/iterm2"
	"github.com/charmbracelet/x/ansi/kitty"
	"github.com/charmbracelet/x/ansi/sixel"
	"github.com/charmbracelet/x/mosaic"
)

type imageEncoding uint8

const (
	blocksEncoding imageEncoding = iota + 1
	sixelEncoding
	itermEncoding
	kittyEncoding

	unknownEncoding = 0
)

func (e imageEncoding) String() string {
	switch e {
	case blocksEncoding:
		return "blocks"
	case sixelEncoding:
		return "sixel"
	case itermEncoding:
		return "iterm"
	case kittyEncoding:
		return "kitty"
	default:
		return "unknown"
	}
}

var desiredEnc int

func init() {
	flag.IntVar(&desiredEnc, "encoding", int(unknownEncoding), "image encoding")

	f, err := os.OpenFile("image.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
	if err != nil {
		log.Fatalf("failed to open log file: %v", err)
	}
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	log.SetOutput(f)
}

func main() {
	flag.Parse()

	t := uv.DefaultTerminal()

	// Use altscreen buffer.
	t.EnterAltScreen() //nolint:errcheck

	// Enable mouse support.
	t.EnableMouse() //nolint:errcheck

	if err := t.Start(); err != nil {
		log.Fatalf("failed to start program: %v", err)
	}

	// Get image info.
	charmImgFile, err := os.Open("./charm.jpg")
	if err != nil {
		log.Fatalf("failed to open image file: %v", err)
	}

	defer charmImgFile.Close() //nolint:errcheck
	charmImgStat, err := charmImgFile.Stat()
	if err != nil {
		log.Fatalf("failed to stat image file: %v", err)
	}

	charmImgFileSize := charmImgStat.Size()

	var charmImgBuf bytes.Buffer
	var charmImgB64 []byte
	imgTee := io.TeeReader(charmImgFile, &charmImgBuf)
	charmImg, _, err := image.Decode(imgTee)
	if err != nil {
		log.Fatalf("failed to decode image: %v", err)
	}

	charmImgArea := charmImg.Bounds()

	// Image related variables.
	var (
		winSize uv.WindowSizeEvent
		pixSize uv.WindowPixelSizeEvent
		imgEnc  = blocksEncoding
	)
	if desiredEnc > 0 {
		imgEnc = imageEncoding(desiredEnc)
	}

	winSize.Width, winSize.Height, err = t.GetSize()
	if err != nil {
		log.Fatalf("failed to get terminal size: %v", err)
	}

	t.Resize(winSize.Width, winSize.Height) //nolint:errcheck

	upgradeEnc := func(enc imageEncoding) {
		if desiredEnc == unknownEncoding {
			if enc > imgEnc {
				imgEnc = enc
			}
		}
	}

	// Check environment variables for supported encodings.
	var (
		termType    = os.Getenv("TERM")
		termProg    string
		lcTerm      string
		termVersion string
		ok          bool
	)
	if termProg, ok = os.LookupEnv("TERM_PROGRAM"); ok {
		if strings.Contains(termProg, "iTerm") ||
			strings.Contains(termProg, "WezTerm") ||
			strings.Contains(termProg, "mintty") ||
			strings.Contains(termProg, "vscode") ||
			strings.Contains(termProg, "Tabby") ||
			strings.Contains(termProg, "Hyper") ||
			strings.Contains(termProg, "rio") {
			upgradeEnc(itermEncoding)
		}
		if lcTerm, ok = os.LookupEnv("LC_TERMINAL"); ok {
			if strings.Contains(lcTerm, "iTerm") {
				upgradeEnc(itermEncoding)
			}
		}
	}

	// Display image methods.
	imgCellSize := func() (int, int) {
		if winSize.Width == 0 || winSize.Height == 0 || pixSize.Width == 0 || pixSize.Height == 0 {
			return 0, 0
		}

		cellW, cellH := pixSize.Width/winSize.Width, pixSize.Height/winSize.Height
		imgW, imgH := charmImgArea.Dx(), charmImgArea.Dy()
		return imgW / cellW, imgH / cellH
	}

	var transmitKitty bool
	var imgCellW, imgCellH int
	var imgOffsetX, imgOffsetY int
	imgCellW, imgCellH = imgCellSize()
	imgOffsetX = winSize.Width/2 - imgCellW/2
	imgOffsetY = winSize.Height/2 - imgCellH/2

	fillStyle := uv.Style{Fg: ansi.IndexedColor(240)}
	displayImg := func() {
		img := charmImg
		imgArea := uv.Rect(
			imgOffsetX,
			imgOffsetY,
			imgCellW,
			imgCellH,
		)
		if !imgArea.In(winSize.Bounds()) {
			imgArea = imgArea.Intersect(winSize.Bounds())
			// TODO: Crop image.
		}

		log.Printf("image area: %v", imgArea)

		// Clear the screen.
		t.Clear()
		fill := uv.Cell{Content: "/", Width: 1, Style: fillStyle}
		t.Fill(&fill)

		// Draw the image on the screen.
		switch imgEnc {
		case blocksEncoding:
			blocks := mosaic.New().Width(imgCellW).Height(imgCellH).Scale(2)
			ss := uv.NewStyledString(blocks.Render(img))
			ss.Draw(t, imgArea)

		case itermEncoding, sixelEncoding:
			t.FillArea(&uv.EmptyCell, imgArea)

		case kittyEncoding:
			const imgId = 31 // random id for kitty graphics
			if !transmitKitty {
				var buf bytes.Buffer
				if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
					ID:               imgId,
					Action:           kitty.TransmitAndPut,
					Transmission:     kitty.Direct,
					Format:           kitty.RGBA,
					Size:             int(charmImgFileSize),
					ImageWidth:       charmImgArea.Dx(),
					ImageHeight:      charmImgArea.Dy(),
					Columns:          imgArea.Dx(),
					Rows:             imgArea.Dy(),
					VirtualPlacement: true,
					Quite:            2,
				}); err != nil {
					log.Fatalf("failed to encode image for Kitty Graphics: %v", err)
				}

				t.WriteString(buf.String()) //nolint:errcheck
				transmitKitty = true
				t.Flush()
			}

			// Build Kitty graphics unicode place holders
			var fg color.Color
			var extra int
			var r, g, b int
			extra, r, g, b = imgId>>24&0xff, imgId>>16&0xff, imgId>>8&0xff, imgId&0xff

			if r == 0 && g == 0 {
				fg = ansi.IndexedColor(b)
			} else {
				fg = color.RGBA{
					R: uint8(r), //nolint:gosec
					G: uint8(g), //nolint:gosec
					B: uint8(b), //nolint:gosec
					A: 0xff,
				}
			}

			for y := 0; y < imgArea.Dy(); y++ {
				// As an optimization, we only write the fg color sequence id, and
				// column-row data once on the first cell. The terminal will handle
				// the rest.
				content := []rune{kitty.Placeholder, kitty.Diacritic(y), kitty.Diacritic(0)}
				if extra > 0 {
					content = append(content, kitty.Diacritic(extra))
				}
				t.SetCell(imgArea.Min.X, imgArea.Min.Y+y, &uv.Cell{
					Style:   uv.Style{Fg: fg},
					Content: string(content),
					Width:   1,
				})
				for x := 1; x < imgArea.Dx(); x++ {
					t.SetCell(imgArea.Min.X+x, imgArea.Min.Y+y, &uv.Cell{
						Style:   uv.Style{Fg: fg},
						Content: string(kitty.Placeholder),
						Width:   1,
					})
				}
			}

		}

		t.Display() //nolint:errcheck

		switch imgEnc {
		case sixelEncoding:
			var senc sixel.Encoder
			var buf bytes.Buffer
			senc.Encode(&buf, img)
			six := ansi.SixelGraphics(0, 1, 0, buf.Bytes())
			// Note: Sixel starts drawing from the current cursor position
			// and the cursor ends up at the bottom of the image
			// Add a small offset to prevent top cutoff
			if imgArea.Min.Y > 0 {
				t.MoveTo(imgArea.Min.X, imgArea.Min.Y+1)
			} else {
				t.MoveTo(imgArea.Min.X, imgArea.Min.Y)
			}

			t.WriteString(six) //nolint:errcheck
			t.WriteString(ansi.CursorPosition(imgArea.Min.X+1, imgArea.Min.Y+1))

		case itermEncoding:
			// Now, we need to encode the image and place it in the first
			// cell before moving the cursor to the correct position.
			if charmImgB64 == nil {
				// Encode the image to base64 for the first time.
				charmImgB64 = []byte(base64.StdEncoding.EncodeToString(charmImgBuf.Bytes()))
			}

			data := ansi.ITerm2(iterm2.File{
				Name:              "charm.jpg",
				Width:             iterm2.Cells(imgArea.Dx()),
				Height:            iterm2.Cells(imgArea.Dy()),
				Inline:            true,
				Content:           charmImgB64,
				IgnoreAspectRatio: true,
			})

			cup := ansi.CursorPosition(imgArea.Min.X+1, imgArea.Min.Y+1)
			t.MoveTo(imgArea.Min.X, imgArea.Min.Y)
			t.WriteString(data) //nolint:errcheck
			t.WriteString(cup)  //nolint:errcheck
		}

		if t.Buffered() > 0 {
			t.Flush() //nolint:errcheck
		}
	}

	// Query image encoding support.
	t.WriteString(ansi.RequestPrimaryDeviceAttributes)        // Query Sixel support.
	t.WriteString(ansi.RequestNameVersion)                    // Query terminal version and name.
	t.WriteString(ansi.WindowOp(ansi.RequestWindowSizeWinOp)) // Request window size.
	// Query Kitty Graphics support using random id=31.
	t.WriteString(ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24"))

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	evch := make(chan uv.Event)
	go func() {
		defer close(evch)
		_ = t.StreamEvents(ctx, evch)
	}()

	// Listen for input events.
	for ev := range evch {
		switch ev := ev.(type) {
		case uv.WindowPixelSizeEvent:
			// XXX: This is only emitted with traditional Unix systems. On
			// Windows, we would need to use [ansi.RequestWindowSizeWinOp] to
			// get the pixel size.
			pixSize = ev
			imgCellW, imgCellH = imgCellSize()
			imgOffsetX = winSize.Width/2 - imgCellW/2
			imgOffsetY = winSize.Height/2 - imgCellH/2
			displayImg()
		case uv.WindowSizeEvent:
			winSize = ev
			imgCellW, imgCellH = imgCellSize()
			imgOffsetX = winSize.Width/2 - imgCellW/2
			imgOffsetY = winSize.Height/2 - imgCellH/2
			t.Erase()
			log.Printf("image cell size: %d x %d", imgCellW, imgCellH)
			if err := t.Resize(ev.Width, ev.Height); err != nil {
				log.Fatalf("failed to resize program: %v", err)
			}

			displayImg()
		case uv.KeyPressEvent:
			switch {
			case ev.MatchStrings("q", "ctrl+c"):
				cancel() // This will stop the loop
			case ev.MatchStrings("up", "k"):
				imgOffsetY--
			case ev.MatchStrings("down", "j"):
				imgOffsetY++
			case ev.MatchStrings("left", "h"):
				imgOffsetX--
			case ev.MatchStrings("right", "l"):
				imgOffsetX++
			}

			displayImg()
		case uv.MouseClickEvent:
			imgOffsetX = ev.X - (imgCellW / 2)
			imgOffsetY = ev.Y - (imgCellH / 2)

			displayImg()
		case uv.PrimaryDeviceAttributesEvent:
			if slices.Contains(ev, 4) {
				upgradeEnc(sixelEncoding)
				displayImg()
			}

		case uv.TerminalVersionEvent:
			if strings.Contains(string(ev), "iTerm") || strings.Contains(string(ev), "WezTerm") {
				upgradeEnc(itermEncoding)
				displayImg()
			}

		case uv.WindowOpEvent:
			// The [ansi.RequestWindowSizeWinOp] request responds with a "4" or
			// [ansi.ResizeWindowWinOp] first parameter.
			if ev.Op == ansi.ResizeWindowWinOp && len(ev.Args) >= 2 {
				pixSize.Height = ev.Args[0]
				pixSize.Width = ev.Args[1]
			}
		case uv.KittyGraphicsEvent:
			if strings.Contains(termType, "wezterm") ||
				strings.Contains(termVersion, "WezTerm") ||
				strings.Contains(termProg, "WezTerm") {
				// WezTerm doesn't support Kitty Unicode Graphics
				break
			}
			if ev.Options.ID == 31 {
				upgradeEnc(kittyEncoding)
			}

			displayImg()
		}
	}

	ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := t.Shutdown(ctx); err != nil {
		log.Fatalf("failed to shutdown program: %v", err)
	}

	fmt.Println("image encoding:", imgEnc)
}

func init() {
	f, err := os.OpenFile("uv_debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
	if err != nil {
		log.Fatalf("failed to open log file: %v", err)
	}

	log.SetOutput(f)
}