From e5e7b75e74bc70040942d51db4196a40f23fe78d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 10 Jan 2025 00:30:06 +0300 Subject: [PATCH 1/6] feat(ansi): kitty graphics: implement basic protocol features This adds support for writing images in the Kitty Graphics protocol. The protocol is used to transmit images over the terminal using escape sequences. The protocol supports 24-bit RGB, 32-bit RGBA, and PNG formats. Images can be compressed using zlib and transmitted in chunks. --- ansi/graphics.go | 197 ++++++++++++++++++++++++++++++++ ansi/kitty/decoder.go | 85 ++++++++++++++ ansi/kitty/encoder.go | 64 +++++++++++ ansi/kitty/graphics.go | 91 +++++++++++++++ ansi/kitty/options.go | 251 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 688 insertions(+) create mode 100644 ansi/graphics.go create mode 100644 ansi/kitty/decoder.go create mode 100644 ansi/kitty/encoder.go create mode 100644 ansi/kitty/graphics.go create mode 100644 ansi/kitty/options.go diff --git a/ansi/graphics.go b/ansi/graphics.go new file mode 100644 index 00000000..aeaa036d --- /dev/null +++ b/ansi/graphics.go @@ -0,0 +1,197 @@ +package ansi + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "io" + "os" + "strings" + + "github.com/charmbracelet/x/ansi/kitty" +) + +// KittyGraphics returns a sequence that encodes the given image in the Kitty +// graphics protocol. +// +// APC G [comma separated options] ; [base64 encoded payload] ST +// +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/ +func KittyGraphics(payload []byte, opts ...string) string { + var buf bytes.Buffer + buf.WriteString("\x1b_G") + buf.WriteString(strings.Join(opts, ",")) + if len(payload) > 0 { + buf.WriteString(";") + buf.Write(payload) + } + buf.WriteString("\x1b\\") + return buf.String() +} + +var ( + // KittyGraphicsTempDir is the directory where temporary files are stored. + // This is used in [WriteKittyGraphics] along with [os.CreateTemp]. + KittyGraphicsTempDir = "" + + // KittyGraphicsTempPattern is the pattern used to create temporary files. + // This is used in [WriteKittyGraphics] along with [os.CreateTemp]. + // The Kitty Graphics protocol requires the file path to contain the + // substring "tty-graphics-protocol". + KittyGraphicsTempPattern = "tty-graphics-protocol-*" +) + +// WriteKittyGraphics writes an image using the Kitty Graphics protocol with +// the given options to w. It chunks the written data if o.Chunk is true. +// +// You can omit m and use nil when rendering an image from a file. In this +// case, you must provide a file path in o.File and use o.Transmission = +// [kitty.File]. You can also use o.Transmission = [kitty.TempFile] to write +// the image to a temporary file. In that case, the file path is ignored, and +// the image is written to a temporary file that is automatically deleted by +// the terminal. +// +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/ +func WriteKittyGraphics(w io.Writer, m image.Image, o *kitty.Options) error { + if o == nil { + o = &kitty.Options{} + } + + if o.Transmission == 0 && len(o.File) != 0 { + o.Transmission = kitty.File + } + + var data bytes.Buffer // the data to be encoded into base64 + e := &kitty.Encoder{ + Compress: o.Compression == kitty.Zlib, + Format: o.Format, + } + + switch o.Transmission { + case kitty.Direct: + if err := e.Encode(&data, m); err != nil { + return fmt.Errorf("failed to encode direct image: %w", err) + } + + case kitty.SharedMemory: + // TODO: Implement shared memory + return fmt.Errorf("shared memory transmission is not yet implemented") + + case kitty.File: + if len(o.File) == 0 { + return kitty.ErrMissingFile + } + + f, err := os.Open(o.File) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + mode := stat.Mode() + if !mode.IsRegular() { + return fmt.Errorf("file is not a regular file") + } + + // Write the file path to the buffer + if _, err := data.WriteString(f.Name()); err != nil { + return fmt.Errorf("failed to write file path to buffer: %w", err) + } + + case kitty.TempFile: + f, err := os.CreateTemp(KittyGraphicsTempDir, KittyGraphicsTempPattern) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer f.Close() //nolint:errcheck + + if err := e.Encode(f, m); err != nil { + return fmt.Errorf("failed to encode image to file: %w", err) + } + + // Write the file path to the buffer + if _, err := data.WriteString(f.Name()); err != nil { + return fmt.Errorf("failed to write file path to buffer: %w", err) + } + } + + // Encode image to base64 + var payload bytes.Buffer // the base64 encoded image to be written to w + b64 := base64.NewEncoder(base64.StdEncoding, &payload) + if _, err := data.WriteTo(b64); err != nil { + return fmt.Errorf("failed to write base64 encoded image to payload: %w", err) + } + if err := b64.Close(); err != nil { + return err + } + + // If not chunking, write all at once + if !o.Chunk { + _, err := io.WriteString(w, KittyGraphics(payload.Bytes(), o.Options()...)) + return err + } + + // Write in chunks + var ( + err error + n int + ) + chunk := make([]byte, kitty.MaxChunkSize) + isFirstChunk := true + + for { + // Stop if we read less than the chunk size [kitty.MaxChunkSize]. + n, err = io.ReadFull(&payload, chunk) + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("failed to read chunk: %w", err) + } + + opts := buildChunkOptions(o, isFirstChunk, false) + if _, err := io.WriteString(w, KittyGraphics(chunk[:n], opts...)); err != nil { + return err + } + + isFirstChunk = false + } + + // Write the last chunk + opts := buildChunkOptions(o, isFirstChunk, true) + _, err = io.WriteString(w, KittyGraphics(chunk[:n], opts...)) + return err +} + +// buildChunkOptions creates the options slice for a chunk +func buildChunkOptions(o *kitty.Options, isFirstChunk, isLastChunk bool) []string { + var opts []string + if isFirstChunk { + opts = o.Options() + } else { + // These options are allowed in subsequent chunks + if o.Quite > 0 { + opts = append(opts, fmt.Sprintf("q=%d", o.Quite)) + } + if o.Action == kitty.Frame { + opts = append(opts, "a=f") + } + } + + if !isFirstChunk || !isLastChunk { + // We don't need to encode the (m=) option when we only have one chunk. + if isLastChunk { + opts = append(opts, "m=0") + } else { + opts = append(opts, "m=1") + } + } + return opts +} diff --git a/ansi/kitty/decoder.go b/ansi/kitty/decoder.go new file mode 100644 index 00000000..fbd08441 --- /dev/null +++ b/ansi/kitty/decoder.go @@ -0,0 +1,85 @@ +package kitty + +import ( + "compress/zlib" + "fmt" + "image" + "image/color" + "image/png" + "io" +) + +// Decoder is a decoder for the Kitty graphics protocol. It supports decoding +// images in the 24-bit [RGB], 32-bit [RGBA], and [PNG] formats. It can also +// decompress data using zlib. +// The default format is 32-bit [RGBA]. +type Decoder struct { + // Uses zlib decompression. + Decompress bool + + // Can be one of [RGB], [RGBA], or [PNG]. + Format int + + // Width of the image in pixels. This can be omitted if the image is [PNG] + // formatted. + Width int + + // Height of the image in pixels. This can be omitted if the image is [PNG] + // formatted. + Height int +} + +// Decode decodes the image data from r in the specified format. +func (d *Decoder) Decode(r io.Reader) (image.Image, error) { + if d.Decompress { + zr, err := zlib.NewReader(r) + if err != nil { + return nil, fmt.Errorf("failed to create zlib reader: %w", err) + } + + defer zr.Close() //nolint:errcheck + r = zr + } + + if d.Format == 0 { + d.Format = RGBA + } + + switch d.Format { + case RGBA, RGB: + return d.decodeRGBA(r, d.Format == RGBA) + + case PNG: + return png.Decode(r) + + default: + return nil, fmt.Errorf("unsupported format: %d", d.Format) + } +} + +// decodeRGBA decodes the image data in 32-bit RGBA or 24-bit RGB formats. +func (d *Decoder) decodeRGBA(r io.Reader, alpha bool) (image.Image, error) { + m := image.NewRGBA(image.Rect(0, 0, d.Width, d.Height)) + + var buf []byte + if alpha { + buf = make([]byte, 4) + } else { + buf = make([]byte, 3) + } + + for y := 0; y < d.Height; y++ { + for x := 0; x < d.Width; x++ { + if _, err := io.ReadFull(r, buf[:]); err != nil { + return nil, fmt.Errorf("failed to read pixel data: %w", err) + } + if alpha { + m.SetRGBA(x, y, color.RGBA{buf[0], buf[1], buf[2], buf[3]}) + } else { + m.SetRGBA(x, y, color.RGBA{buf[0], buf[1], buf[2], 0xff}) + } + } + } + + return m, nil +} diff --git a/ansi/kitty/encoder.go b/ansi/kitty/encoder.go new file mode 100644 index 00000000..f668b9e3 --- /dev/null +++ b/ansi/kitty/encoder.go @@ -0,0 +1,64 @@ +package kitty + +import ( + "compress/zlib" + "fmt" + "image" + "image/png" + "io" +) + +// Encoder is an encoder for the Kitty graphics protocol. It supports encoding +// images in the 24-bit [RGB], 32-bit [RGBA], and [PNG] formats, and +// compressing the data using zlib. +// The default format is 32-bit [RGBA]. +type Encoder struct { + // Uses zlib compression. + Compress bool + + // Can be one of [RGBA], [RGB], or [PNG]. + Format int +} + +// Encode encodes the image data in the specified format and writes it to w. +func (e *Encoder) Encode(w io.Writer, m image.Image) error { + if m == nil { + return nil + } + + if e.Compress { + zw := zlib.NewWriter(w) + defer zw.Close() //nolint:errcheck + w = zw + } + + if e.Format == 0 { + e.Format = RGBA + } + + switch e.Format { + case RGBA, RGB: + bounds := m.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := m.At(x, y).RGBA() + switch e.Format { + case RGBA: + w.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)}) //nolint:errcheck + case RGB: + w.Write([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8)}) //nolint:errcheck + } + } + } + + case PNG: + if err := png.Encode(w, m); err != nil { + return fmt.Errorf("failed to encode PNG: %w", err) + } + + default: + return fmt.Errorf("unsupported format: %d", e.Format) + } + + return nil +} diff --git a/ansi/kitty/graphics.go b/ansi/kitty/graphics.go new file mode 100644 index 00000000..031145bc --- /dev/null +++ b/ansi/kitty/graphics.go @@ -0,0 +1,91 @@ +package kitty + +// Graphics image format. +const ( + // 32-bit RGBA format. + RGBA = 32 + + // 24-bit RGB format. + RGB = 24 + + // PNG format. + PNG = 100 +) + +// Compression types. +const ( + Zlib = 'z' +) + +// Transmission types. +const ( + // The data transmitted directly in the escape sequence. + Direct = 'd' + + // The data transmitted in a regular file. + File = 'f' + + // A temporary file is used and deleted after transmission. + TempFile = 't' + + // A shared memory object. + // For POSIX see https://pubs.opengroup.org/onlinepubs/9699919799/functions/shm_open.html + // For Windows see https://docs.microsoft.com/en-us/windows/win32/memory/creating-named-shared-memory + SharedMemory = 's' +) + +// Action types. +const ( + // Transmit image data. + Transmit = 't' + // TransmitAndPut transmit image data and display (put) it. + TransmitAndPut = 'T' + // Query terminal for image info. + Query = 'q' + // Put (display) previously transmitted image. + Put = 'p' + // Delete image. + Delete = 'd' + // Frame transmits data for animation frames. + Frame = 'f' + // Animate controls animation. + Animate = 'a' + // Compose composes animation frames. + Compose = 'c' +) + +// Delete types. +const ( + // Delete all placements visible on screen + DeleteAll = 'a' + // Delete all images with the specified id, specified using the i key. If + // you specify a p key for the placement id as well, then only the + // placement with the specified image id and placement id will be deleted. + DeleteID = 'i' + // Delete newest image with the specified number, specified using the I + // key. If you specify a p key for the placement id as well, then only the + // placement with the specified number and placement id will be deleted. + DeleteNumber = 'n' + // Delete all placements that intersect with the current cursor position. + DeleteCursor = 'c' + // Delete animation frames. + DeleteFrames = 'f' + // Delete all placements that intersect a specific cell, the cell is + // specified using the x and y keys + DeleteCell = 'p' + // Delete all placements that intersect a specific cell having a specific + // z-index. The cell and z-index is specified using the x, y and z keys. + DeleteCellZ = 'q' + // Delete all images whose id is greater than or equal to the value of the x + // key and less than or equal to the value of the y. + DeleteRange = 'r' + // Delete all placements that intersect the specified column, specified using + // the x key. + DeleteColumn = 'x' + // Delete all placements that intersect the specified row, specified using + // the y key. + DeleteRow = 'y' + // Delete all placements that have the specified z-index, specified using the + // z key. + DeleteZ = 'z' +) diff --git a/ansi/kitty/options.go b/ansi/kitty/options.go new file mode 100644 index 00000000..b7ceb1eb --- /dev/null +++ b/ansi/kitty/options.go @@ -0,0 +1,251 @@ +package kitty + +import ( + "fmt" +) + +// Options represents a Kitty Graphics Protocol options. +type Options struct { + // Common options. + + // Action (a=t) is the action to be performed on the image. Can be one of + // [Transmit], [TransmitDisplay], [Query], [Put], [Delete], [Frame], + // [Animate], [Compose]. + Action byte + + // Quite mode (q=0) is the quiet mode. Can be either zero, one, or two + // where zero is the default, 1 suppresses OK responses, and 2 suppresses + // both OK and error responses. + Quite byte + + // Transmission options. + + // ID (i=) is the image ID. The ID is a unique identifier for the image. + // Must be a positive integer up to [math.MaxUint32]. + ID int + + // PlacementID (p=) is the placement ID. The placement ID is a unique + // identifier for the placement of the image. Must be a positive integer up + // to [math.MaxUint32]. + PlacementID int + + // Number (I=0) is the number of images to be transmitted. + Number int + + // Format (f=32) is the image format. One of [RGBA], [RGB], [PNG]. + Format int + + // ImageWidth (s=0) is the transmitted image width. + ImageWidth int + + // ImageHeight (v=0) is the transmitted image height. + ImageHeight int + + // Compression (o=) is the image compression type. Can be [Zlib] or zero. + Compression byte + + // Transmission (t=d) is the image transmission type. Can be [Direct], [File], + // [TempFile], or [SharedMemory]. + Transmission byte + + // File is the file path to be used when the transmission type is [File]. + // If [Options.Transmission] is omitted i.e. zero and this is non-empty, + // the transmission type is set to [File]. + File string + + // Size (S=0) is the size to be read from the transmission medium. + Size int + + // Offset (O=0) is the offset byte to start reading from the transmission + // medium. + Offset int + + // Chunk (m=) whether the image is transmitted in chunks. Can be either + // zero or one. When true, the image is transmitted in chunks. Each chunk + // must be a multiple of 4, and up to [MaxChunkSize] bytes. Each chunk must + // have the m=1 option except for the last chunk which must have m=0. + Chunk bool + + // Display options. + + // X (x=0) is the pixel X coordinate of the image to start displaying. + X int + + // Y (y=0) is the pixel Y coordinate of the image to start displaying. + Y int + + // Z (z=0) is the Z coordinate of the image to display. + Z int + + // Width (w=0) is the width of the image to display. + Width int + + // Height (h=0) is the height of the image to display. + Height int + + // OffsetX (X=0) is the OffsetX coordinate of the cursor cell to start + // displaying the image. OffsetX=0 is the leftmost cell. This must be + // smaller than the terminal cell width. + OffsetX int + + // OffsetY (Y=0) is the OffsetY coordinate of the cursor cell to start + // displaying the image. OffsetY=0 is the topmost cell. This must be + // smaller than the terminal cell height. + OffsetY int + + // Columns (c=0) is the number of columns to display the image. The image + // will be scaled to fit the number of columns. + Columns int + + // Rows (r=0) is the number of rows to display the image. The image will be + // scaled to fit the number of rows. + Rows int + + // VirtualPlacement (V=0) whether to use virtual placement. This is used + // with Unicode [Placeholder] to display images. + VirtualPlacement bool + + // ParentID (P=0) is the parent image ID. The parent ID is the ID of the + // image that is the parent of the current image. This is used with Unicode + // [Placeholder] to display images relative to the parent image. + ParentID int + + // ParentPlacementID (Q=0) is the parent placement ID. The parent placement + // ID is the ID of the placement of the parent image. This is used with + // Unicode [Placeholder] to display images relative to the parent image. + ParentPlacementID int + + // Delete options. + + // Delete (d=a) is the delete action. Can be one of [DeleteAll], + // [DeleteID], [DeleteNumber], [DeleteCursor], [DeleteFrames], + // [DeleteCell], [DeleteCellZ], [DeleteRange], [DeleteColumn], [DeleteRow], + // [DeleteZ]. + Delete byte + + // DeleteResources indicates whether to delete the resources associated + // with the image. + DeleteResources bool +} + +// Options returns the options as a slice of a key-value pairs. +func (o *Options) Options() (opts []string) { + if o.Format == 0 { + o.Format = RGBA + } + + if o.Action == 0 { + o.Action = Transmit + } + + if o.Delete == 0 { + o.Delete = DeleteAll + } + + if o.Format != RGBA { + opts = append(opts, fmt.Sprintf("f=%d", o.Format)) + } + + if o.Quite > 0 { + opts = append(opts, fmt.Sprintf("q=%d", o.Quite)) + } + + if o.ID > 0 { + opts = append(opts, fmt.Sprintf("i=%d", o.ID)) + } + + if o.PlacementID > 0 { + opts = append(opts, fmt.Sprintf("p=%d", o.PlacementID)) + } + + if o.Number > 0 { + opts = append(opts, fmt.Sprintf("I=%d", o.Number)) + } + + if o.ImageWidth > 0 { + opts = append(opts, fmt.Sprintf("s=%d", o.ImageWidth)) + } + + if o.ImageHeight > 0 { + opts = append(opts, fmt.Sprintf("v=%d", o.ImageHeight)) + } + + if o.Transmission != Direct { + opts = append(opts, fmt.Sprintf("t=%c", o.Transmission)) + } + + if o.Size > 0 { + opts = append(opts, fmt.Sprintf("S=%d", o.Size)) + } + + if o.Offset > 0 { + opts = append(opts, fmt.Sprintf("O=%d", o.Offset)) + } + + if o.Compression == Zlib { + opts = append(opts, fmt.Sprintf("o=%c", o.Compression)) + } + + if o.VirtualPlacement { + opts = append(opts, "U=1") + } + + if o.ParentID > 0 { + opts = append(opts, fmt.Sprintf("P=%d", o.ParentID)) + } + + if o.ParentPlacementID > 0 { + opts = append(opts, fmt.Sprintf("Q=%d", o.ParentPlacementID)) + } + + if o.X > 0 { + opts = append(opts, fmt.Sprintf("x=%d", o.X)) + } + + if o.Y > 0 { + opts = append(opts, fmt.Sprintf("y=%d", o.Y)) + } + + if o.Z > 0 { + opts = append(opts, fmt.Sprintf("z=%d", o.Z)) + } + + if o.Width > 0 { + opts = append(opts, fmt.Sprintf("w=%d", o.Width)) + } + + if o.Height > 0 { + opts = append(opts, fmt.Sprintf("h=%d", o.Height)) + } + + if o.OffsetX > 0 { + opts = append(opts, fmt.Sprintf("X=%d", o.OffsetX)) + } + + if o.OffsetY > 0 { + opts = append(opts, fmt.Sprintf("Y=%d", o.OffsetY)) + } + + if o.Columns > 0 { + opts = append(opts, fmt.Sprintf("c=%d", o.Columns)) + } + + if o.Rows > 0 { + opts = append(opts, fmt.Sprintf("r=%d", o.Rows)) + } + + if o.Delete != DeleteAll || o.DeleteResources { + da := o.Delete + if o.DeleteResources { + da = da - 'A' // to uppercase + } + + opts = append(opts, fmt.Sprintf("d=%c", da)) + } + + if o.Action != Transmit { + opts = append(opts, fmt.Sprintf("a=%c", o.Action)) + } + + return +} From 4f7f271a0476462db1da0db4d19e19816f1bd50e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 10 Jan 2025 22:01:54 +0300 Subject: [PATCH 2/6] feat(ansi): kitty graphics: support unicode placeholders --- ansi/kitty/graphics.go | 323 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/ansi/kitty/graphics.go b/ansi/kitty/graphics.go index 031145bc..490e7a8a 100644 --- a/ansi/kitty/graphics.go +++ b/ansi/kitty/graphics.go @@ -1,5 +1,17 @@ package kitty +import "errors" + +// ErrMissingFile is returned when the file path is missing. +var ErrMissingFile = errors.New("missing file path") + +// MaxChunkSize is the maximum chunk size for the image data. +const MaxChunkSize = 1024 * 4 + +// Placeholder is a special Unicode character that can be used as a placeholder +// for an image. +const Placeholder = '\U0010EEEE' + // Graphics image format. const ( // 32-bit RGBA format. @@ -89,3 +101,314 @@ const ( // z key. DeleteZ = 'z' ) + +// Diacritic returns the diacritic rune at the specified index. If the index is +// out of bounds, the first diacritic rune is returned. +func Diacritic(i int) rune { + if i < 0 || i >= len(diacritics) { + return diacritics[0] + } + return diacritics[i] +} + +// From https://sw.kovidgoyal.net/kitty/_downloads/f0a0de9ec8d9ff4456206db8e0814937/rowcolumn-diacritics.txt +// See https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders for further explanation. +var diacritics = []rune{ + '\u0305', + '\u030D', + '\u030E', + '\u0310', + '\u0312', + '\u033D', + '\u033E', + '\u033F', + '\u0346', + '\u034A', + '\u034B', + '\u034C', + '\u0350', + '\u0351', + '\u0352', + '\u0357', + '\u035B', + '\u0363', + '\u0364', + '\u0365', + '\u0366', + '\u0367', + '\u0368', + '\u0369', + '\u036A', + '\u036B', + '\u036C', + '\u036D', + '\u036E', + '\u036F', + '\u0483', + '\u0484', + '\u0485', + '\u0486', + '\u0487', + '\u0592', + '\u0593', + '\u0594', + '\u0595', + '\u0597', + '\u0598', + '\u0599', + '\u059C', + '\u059D', + '\u059E', + '\u059F', + '\u05A0', + '\u05A1', + '\u05A8', + '\u05A9', + '\u05AB', + '\u05AC', + '\u05AF', + '\u05C4', + '\u0610', + '\u0611', + '\u0612', + '\u0613', + '\u0614', + '\u0615', + '\u0616', + '\u0617', + '\u0657', + '\u0658', + '\u0659', + '\u065A', + '\u065B', + '\u065D', + '\u065E', + '\u06D6', + '\u06D7', + '\u06D8', + '\u06D9', + '\u06DA', + '\u06DB', + '\u06DC', + '\u06DF', + '\u06E0', + '\u06E1', + '\u06E2', + '\u06E4', + '\u06E7', + '\u06E8', + '\u06EB', + '\u06EC', + '\u0730', + '\u0732', + '\u0733', + '\u0735', + '\u0736', + '\u073A', + '\u073D', + '\u073F', + '\u0740', + '\u0741', + '\u0743', + '\u0745', + '\u0747', + '\u0749', + '\u074A', + '\u07EB', + '\u07EC', + '\u07ED', + '\u07EE', + '\u07EF', + '\u07F0', + '\u07F1', + '\u07F3', + '\u0816', + '\u0817', + '\u0818', + '\u0819', + '\u081B', + '\u081C', + '\u081D', + '\u081E', + '\u081F', + '\u0820', + '\u0821', + '\u0822', + '\u0823', + '\u0825', + '\u0826', + '\u0827', + '\u0829', + '\u082A', + '\u082B', + '\u082C', + '\u082D', + '\u0951', + '\u0953', + '\u0954', + '\u0F82', + '\u0F83', + '\u0F86', + '\u0F87', + '\u135D', + '\u135E', + '\u135F', + '\u17DD', + '\u193A', + '\u1A17', + '\u1A75', + '\u1A76', + '\u1A77', + '\u1A78', + '\u1A79', + '\u1A7A', + '\u1A7B', + '\u1A7C', + '\u1B6B', + '\u1B6D', + '\u1B6E', + '\u1B6F', + '\u1B70', + '\u1B71', + '\u1B72', + '\u1B73', + '\u1CD0', + '\u1CD1', + '\u1CD2', + '\u1CDA', + '\u1CDB', + '\u1CE0', + '\u1DC0', + '\u1DC1', + '\u1DC3', + '\u1DC4', + '\u1DC5', + '\u1DC6', + '\u1DC7', + '\u1DC8', + '\u1DC9', + '\u1DCB', + '\u1DCC', + '\u1DD1', + '\u1DD2', + '\u1DD3', + '\u1DD4', + '\u1DD5', + '\u1DD6', + '\u1DD7', + '\u1DD8', + '\u1DD9', + '\u1DDA', + '\u1DDB', + '\u1DDC', + '\u1DDD', + '\u1DDE', + '\u1DDF', + '\u1DE0', + '\u1DE1', + '\u1DE2', + '\u1DE3', + '\u1DE4', + '\u1DE5', + '\u1DE6', + '\u1DFE', + '\u20D0', + '\u20D1', + '\u20D4', + '\u20D5', + '\u20D6', + '\u20D7', + '\u20DB', + '\u20DC', + '\u20E1', + '\u20E7', + '\u20E9', + '\u20F0', + '\u2CEF', + '\u2CF0', + '\u2CF1', + '\u2DE0', + '\u2DE1', + '\u2DE2', + '\u2DE3', + '\u2DE4', + '\u2DE5', + '\u2DE6', + '\u2DE7', + '\u2DE8', + '\u2DE9', + '\u2DEA', + '\u2DEB', + '\u2DEC', + '\u2DED', + '\u2DEE', + '\u2DEF', + '\u2DF0', + '\u2DF1', + '\u2DF2', + '\u2DF3', + '\u2DF4', + '\u2DF5', + '\u2DF6', + '\u2DF7', + '\u2DF8', + '\u2DF9', + '\u2DFA', + '\u2DFB', + '\u2DFC', + '\u2DFD', + '\u2DFE', + '\u2DFF', + '\uA66F', + '\uA67C', + '\uA67D', + '\uA6F0', + '\uA6F1', + '\uA8E0', + '\uA8E1', + '\uA8E2', + '\uA8E3', + '\uA8E4', + '\uA8E5', + '\uA8E6', + '\uA8E7', + '\uA8E8', + '\uA8E9', + '\uA8EA', + '\uA8EB', + '\uA8EC', + '\uA8ED', + '\uA8EE', + '\uA8EF', + '\uA8F0', + '\uA8F1', + '\uAAB0', + '\uAAB2', + '\uAAB3', + '\uAAB7', + '\uAAB8', + '\uAABE', + '\uAABF', + '\uAAC1', + '\uFE20', + '\uFE21', + '\uFE22', + '\uFE23', + '\uFE24', + '\uFE25', + '\uFE26', + '\U00010A0F', + '\U00010A38', + '\U0001D185', + '\U0001D186', + '\U0001D187', + '\U0001D188', + '\U0001D189', + '\U0001D1AA', + '\U0001D1AB', + '\U0001D1AC', + '\U0001D1AD', + '\U0001D242', + '\U0001D243', + '\U0001D244', +} From f27f70e959bec6c3956e5e0b2fe585d46a2d3136 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 13 Jan 2025 09:17:56 +0300 Subject: [PATCH 3/6] fix(ansi): kitty: options validation --- ansi/kitty/decoder_test.go | 252 +++++++++++++++++++++++++++++++++++++ ansi/kitty/encoder_test.go | 242 +++++++++++++++++++++++++++++++++++ ansi/kitty/options.go | 11 +- ansi/kitty/options_test.go | 214 +++++++++++++++++++++++++++++++ 4 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 ansi/kitty/decoder_test.go create mode 100644 ansi/kitty/encoder_test.go create mode 100644 ansi/kitty/options_test.go diff --git a/ansi/kitty/decoder_test.go b/ansi/kitty/decoder_test.go new file mode 100644 index 00000000..61972179 --- /dev/null +++ b/ansi/kitty/decoder_test.go @@ -0,0 +1,252 @@ +package kitty + +import ( + "bytes" + "compress/zlib" + "image" + "image/color" + "image/png" + "reflect" + "testing" +) + +func TestDecoder_Decode(t *testing.T) { + // Helper function to create compressed data + compress := func(data []byte) []byte { + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + w.Write(data) + w.Close() + return buf.Bytes() + } + + tests := []struct { + name string + decoder Decoder + input []byte + want image.Image + wantErr bool + }{ + { + name: "RGBA format 2x2", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + }, + input: []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + }, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "RGB format 2x2", + decoder: Decoder{ + Format: RGB, + Width: 2, + Height: 2, + }, + input: []byte{ + 255, 0, 0, // Red pixel + 0, 0, 255, // Blue pixel + 0, 0, 255, // Blue pixel + 255, 0, 0, // Red pixel + }, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "RGBA with compression", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + Decompress: true, + }, + input: compress([]byte{ + 255, 0, 0, 255, + 0, 0, 255, 255, + 0, 0, 255, 255, + 255, 0, 0, 255, + }), + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "PNG format", + decoder: Decoder{ + Format: PNG, + // Width and height are embedded and inferred from the PNG data + }, + input: func() []byte { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() + }(), + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "invalid format", + decoder: Decoder{ + Format: 999, + Width: 2, + Height: 2, + }, + input: []byte{0, 0, 0}, + want: nil, + wantErr: true, + }, + { + name: "incomplete RGBA data", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + }, + input: []byte{255, 0, 0}, // Incomplete pixel data + want: nil, + wantErr: true, + }, + { + name: "invalid compressed data", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + Decompress: true, + }, + input: []byte{1, 2, 3}, // Invalid zlib data + want: nil, + wantErr: true, + }, + { + name: "default format (RGBA)", + decoder: Decoder{ + Width: 1, + Height: 1, + }, + input: []byte{255, 0, 0, 255}, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.decoder.Decode(bytes.NewReader(tt.input)) + + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() output mismatch") + if bounds := got.Bounds(); bounds != tt.want.Bounds() { + t.Errorf("bounds got %v, want %v", bounds, tt.want.Bounds()) + } + + // Compare pixels + bounds := got.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + gotColor := got.At(x, y) + wantColor := tt.want.At(x, y) + if !reflect.DeepEqual(gotColor, wantColor) { + t.Errorf("pixel at (%d,%d) = %v, want %v", x, y, gotColor, wantColor) + } + } + } + } + }) + } +} + +func TestDecoder_DecodeEdgeCases(t *testing.T) { + tests := []struct { + name string + decoder Decoder + input []byte + wantErr bool + }{ + { + name: "zero dimensions", + decoder: Decoder{ + Format: RGBA, + Width: 0, + Height: 0, + }, + input: []byte{}, + wantErr: false, + }, + { + name: "negative width", + decoder: Decoder{ + Format: RGBA, + Width: -1, + Height: 1, + }, + input: []byte{255, 0, 0, 255}, + wantErr: false, // The image package handles this gracefully + }, + { + name: "very large dimensions", + decoder: Decoder{ + Format: RGBA, + Width: 1, + Height: 1000000, // Very large height + }, + input: []byte{255, 0, 0, 255}, // Not enough data + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.decoder.Decode(bytes.NewReader(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/ansi/kitty/encoder_test.go b/ansi/kitty/encoder_test.go new file mode 100644 index 00000000..09154719 --- /dev/null +++ b/ansi/kitty/encoder_test.go @@ -0,0 +1,242 @@ +package kitty + +import ( + "bytes" + "compress/zlib" + "image" + "image/color" + "io" + "testing" +) + +// taken from "image/png" package +const pngHeader = "\x89PNG\r\n\x1a\n" + +// testImage creates a simple test image with a red and blue pattern +func testImage() *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red + return img +} + +func TestEncoder_Encode(t *testing.T) { + tests := []struct { + name string + encoder Encoder + img image.Image + wantErr bool + verify func([]byte) error + }{ + { + name: "nil image", + encoder: Encoder{ + Format: RGBA, + }, + img: nil, + wantErr: false, + verify: func(got []byte) error { + if len(got) != 0 { + t.Errorf("expected empty output for nil image, got %d bytes", len(got)) + } + return nil + }, + }, + { + name: "RGBA format", + encoder: Encoder{ + Format: RGBA, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + { + name: "RGB format", + encoder: Encoder{ + Format: RGB, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, // Red pixel + 0, 0, 255, // Blue pixel + 0, 0, 255, // Blue pixel + 255, 0, 0, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGB output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + { + name: "PNG format", + encoder: Encoder{ + Format: PNG, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + // Verify PNG header + // if len(got) < 8 || !bytes.Equal(got[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) { + if len(got) < 8 || !bytes.Equal(got[:8], []byte(pngHeader)) { + t.Error("invalid PNG header") + } + return nil + }, + }, + { + name: "invalid format", + encoder: Encoder{ + Format: 999, // Invalid format + }, + img: testImage(), + wantErr: true, + verify: nil, + }, + { + name: "RGBA with compression", + encoder: Encoder{ + Format: RGBA, + Compress: true, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + // Decompress the data + r, err := zlib.NewReader(bytes.NewReader(got)) + if err != nil { + return err + } + defer r.Close() + + decompressed, err := io.ReadAll(r) + if err != nil { + return err + } + + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(decompressed, expected) { + t.Errorf("unexpected decompressed output\ngot: %v\nwant: %v", decompressed, expected) + } + return nil + }, + }, + { + name: "zero format defaults to RGBA", + encoder: Encoder{ + Format: 0, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := tt.encoder.Encode(&buf, tt.img) + + if (err != nil) != tt.wantErr { + t.Errorf("Encode() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.verify != nil { + if err := tt.verify(buf.Bytes()); err != nil { + t.Errorf("verification failed: %v", err) + } + } + }) + } +} + +func TestEncoder_EncodeWithDifferentImageTypes(t *testing.T) { + // Create different image types for testing + rgba := image.NewRGBA(image.Rect(0, 0, 1, 1)) + rgba.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + + gray := image.NewGray(image.Rect(0, 0, 1, 1)) + gray.Set(0, 0, color.Gray{Y: 128}) + + tests := []struct { + name string + img image.Image + format int + wantLen int + }{ + { + name: "RGBA image to RGBA format", + img: rgba, + format: RGBA, + wantLen: 4, // 4 bytes per pixel + }, + { + name: "Gray image to RGBA format", + img: gray, + format: RGBA, + wantLen: 4, // 4 bytes per pixel + }, + { + name: "RGBA image to RGB format", + img: rgba, + format: RGB, + wantLen: 3, // 3 bytes per pixel + }, + { + name: "Gray image to RGB format", + img: gray, + format: RGB, + wantLen: 3, // 3 bytes per pixel + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + enc := Encoder{Format: tt.format} + + err := enc.Encode(&buf, tt.img) + if err != nil { + t.Errorf("Encode() error = %v", err) + return + } + + if got := buf.Len(); got != tt.wantLen { + t.Errorf("Encode() output length = %v, want %v", got, tt.wantLen) + } + }) + } +} diff --git a/ansi/kitty/options.go b/ansi/kitty/options.go index b7ceb1eb..1f1ff0b1 100644 --- a/ansi/kitty/options.go +++ b/ansi/kitty/options.go @@ -130,6 +130,7 @@ type Options struct { // Options returns the options as a slice of a key-value pairs. func (o *Options) Options() (opts []string) { + opts = []string{} if o.Format == 0 { o.Format = RGBA } @@ -142,6 +143,14 @@ func (o *Options) Options() (opts []string) { o.Delete = DeleteAll } + if o.Transmission == 0 { + if len(o.File) > 0 { + o.Transmission = File + } else { + o.Transmission = Direct + } + } + if o.Format != RGBA { opts = append(opts, fmt.Sprintf("f=%d", o.Format)) } @@ -237,7 +246,7 @@ func (o *Options) Options() (opts []string) { if o.Delete != DeleteAll || o.DeleteResources { da := o.Delete if o.DeleteResources { - da = da - 'A' // to uppercase + da = da - ' ' // to uppercase } opts = append(opts, fmt.Sprintf("d=%c", da)) diff --git a/ansi/kitty/options_test.go b/ansi/kitty/options_test.go new file mode 100644 index 00000000..2bdef712 --- /dev/null +++ b/ansi/kitty/options_test.go @@ -0,0 +1,214 @@ +package kitty + +import ( + "reflect" + "sort" + "testing" +) + +func TestOptions_Options(t *testing.T) { + tests := []struct { + name string + options Options + expected []string + }{ + { + name: "default options", + options: Options{}, + expected: []string{}, // Default values don't generate options + }, + { + name: "basic transmission options", + options: Options{ + Format: PNG, + ID: 1, + Action: TransmitAndPut, + }, + expected: []string{ + "f=100", + "i=1", + "a=T", + }, + }, + { + name: "display options", + options: Options{ + X: 100, + Y: 200, + Z: 3, + Width: 400, + Height: 300, + }, + expected: []string{ + "x=100", + "y=200", + "z=3", + "w=400", + "h=300", + }, + }, + { + name: "compression and chunking", + options: Options{ + Compression: Zlib, + Chunk: true, + Size: 1024, + }, + expected: []string{ + "S=1024", + "o=z", + }, + }, + { + name: "delete options", + options: Options{ + Delete: DeleteID, + DeleteResources: true, + }, + expected: []string{ + "d=I", // Uppercase due to DeleteResources being true + }, + }, + { + name: "virtual placement", + options: Options{ + VirtualPlacement: true, + ParentID: 5, + ParentPlacementID: 2, + }, + expected: []string{ + "U=1", + "P=5", + "Q=2", + }, + }, + { + name: "cell positioning", + options: Options{ + OffsetX: 10, + OffsetY: 20, + Columns: 80, + Rows: 24, + }, + expected: []string{ + "X=10", + "Y=20", + "c=80", + "r=24", + }, + }, + { + name: "transmission details", + options: Options{ + Transmission: File, + File: "/tmp/image.png", + Offset: 100, + Number: 2, + PlacementID: 3, + }, + expected: []string{ + "p=3", + "I=2", + "t=f", + "O=100", + }, + }, + { + name: "quiet mode and format", + options: Options{ + Quite: 2, + Format: RGB, + }, + expected: []string{ + "f=24", + "q=2", + }, + }, + { + name: "all zero values", + options: Options{ + Format: 0, + Action: 0, + Delete: 0, + }, + expected: []string{}, // Should use defaults and not generate options + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.options.Options() + + // Sort both slices to ensure consistent comparison + sortStrings(got) + sortStrings(tt.expected) + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Options.Options() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestOptions_Validation(t *testing.T) { + tests := []struct { + name string + options Options + check func([]string) bool + }{ + { + name: "format validation", + options: Options{ + Format: 999, // Invalid format + }, + check: func(opts []string) bool { + // Should still output the format even if invalid + return containsOption(opts, "f=999") + }, + }, + { + name: "delete with resources", + options: Options{ + Delete: DeleteID, + DeleteResources: true, + }, + check: func(opts []string) bool { + // Should be uppercase when DeleteResources is true + return containsOption(opts, "d=I") + }, + }, + { + name: "transmission with file", + options: Options{ + File: "/tmp/test.png", + }, + check: func(opts []string) bool { + return containsOption(opts, "t=f") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.options.Options() + if !tt.check(got) { + t.Errorf("Options validation failed for %s: %v", tt.name, got) + } + }) + } +} + +// Helper functions + +func sortStrings(s []string) { + sort.Strings(s) +} + +func containsOption(opts []string, target string) bool { + for _, opt := range opts { + if opt == target { + return true + } + } + return false +} From 44117af11b5233575a8651eb1fc5073d3ff69bf8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 13 Jan 2025 09:31:02 +0300 Subject: [PATCH 4/6] fix(ansi): kitty: closing file --- ansi/graphics.go | 2 + ansi/graphics_test.go | 278 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 ansi/graphics_test.go diff --git a/ansi/graphics.go b/ansi/graphics.go index aeaa036d..604fef47 100644 --- a/ansi/graphics.go +++ b/ansi/graphics.go @@ -89,6 +89,8 @@ func WriteKittyGraphics(w io.Writer, m image.Image, o *kitty.Options) error { return fmt.Errorf("failed to open file: %w", err) } + defer f.Close() //nolint:errcheck + stat, err := f.Stat() if err != nil { return fmt.Errorf("failed to get file info: %w", err) diff --git a/ansi/graphics_test.go b/ansi/graphics_test.go new file mode 100644 index 00000000..41df20f3 --- /dev/null +++ b/ansi/graphics_test.go @@ -0,0 +1,278 @@ +package ansi + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi/kitty" +) + +func TestKittyGraphics(t *testing.T) { + tests := []struct { + name string + payload []byte + opts []string + want string + }{ + { + name: "empty payload no options", + payload: []byte{}, + opts: nil, + want: "\x1b_G\x1b\\", + }, + { + name: "with payload no options", + payload: []byte("test"), + opts: nil, + want: "\x1b_G;test\x1b\\", + }, + { + name: "with payload and options", + payload: []byte("test"), + opts: []string{"a=t", "f=100"}, + want: "\x1b_Ga=t,f=100;test\x1b\\", + }, + { + name: "multiple options no payload", + payload: []byte{}, + opts: []string{"q=2", "C=1", "f=24"}, + want: "\x1b_Gq=2,C=1,f=24\x1b\\", + }, + { + name: "with special characters in payload", + payload: []byte("\x1b_G"), + opts: []string{"a=t"}, + want: "\x1b_Ga=t;\x1b_G\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := KittyGraphics(tt.payload, tt.opts...) + if got != tt.want { + t.Errorf("KittyGraphics() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWriteKittyGraphics(t *testing.T) { + // Create a test image + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + + // Create large test image (larger than [kitty.MaxChunkSize] 4096 bytes) + imgLarge := image.NewRGBA(image.Rect(0, 0, 100, 100)) + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + imgLarge.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + + // Create a temporary test file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-image") + if err := os.WriteFile(tmpFile, []byte("test image data"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + img image.Image + opts *kitty.Options + wantError bool + check func(t *testing.T, output string) + }{ + { + name: "direct transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Format: kitty.RGB, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.HasPrefix(output, "\x1b_G") { + t.Error("output should start with ESC sequence") + } + if !strings.HasSuffix(output, "\x1b\\") { + t.Error("output should end with ST sequence") + } + if !strings.Contains(output, "f=24") { + t.Error("output should contain format specification") + } + }, + }, + { + name: "chunked transmission", + img: imgLarge, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Format: kitty.RGB, + Chunk: true, + }, + wantError: false, + check: func(t *testing.T, output string) { + chunks := strings.Split(output, "\x1b\\") + if len(chunks) < 2 { + t.Error("output should contain multiple chunks") + } + + chunks = chunks[:len(chunks)-1] // Remove last empty chunk + for i, chunk := range chunks { + if i == len(chunks)-1 { + if !strings.Contains(chunk, "m=0") { + t.Errorf("output should contain chunk end-of-data indicator for chunk %d %q", i, chunk) + } + } else { + if !strings.Contains(chunk, "m=1") { + t.Errorf("output should contain chunk indicator for chunk %d %q", i, chunk) + } + } + } + }, + }, + { + name: "file transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.File, + File: tmpFile, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.Contains(output, base64.StdEncoding.EncodeToString([]byte(tmpFile))) { + t.Error("output should contain encoded file path") + } + }, + }, + { + name: "temp file transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.TempFile, + }, + wantError: false, + check: func(t *testing.T, output string) { + output = strings.TrimPrefix(output, "\x1b_G") + output = strings.TrimSuffix(output, "\x1b\\") + payload := strings.Split(output, ";")[1] + fn, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Error("output should contain base64 encoded temp file path") + } + if !strings.Contains(string(fn), "tty-graphics-protocol") { + t.Error("output should contain temp file path") + } + if !strings.Contains(output, "t=t") { + t.Error("output should contain transmission specification") + } + }, + }, + { + name: "compression enabled", + img: img, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Compression: kitty.Zlib, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.Contains(output, "o=z") { + t.Error("output should contain compression specification") + } + }, + }, + { + name: "invalid file path", + img: img, + opts: &kitty.Options{ + Transmission: kitty.File, + File: "/nonexistent/file", + }, + wantError: true, + check: nil, + }, + { + name: "nil options", + img: img, + opts: nil, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.HasPrefix(output, "\x1b_G") { + t.Error("output should start with ESC sequence") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := WriteKittyGraphics(&buf, tt.img, tt.opts) + + if (err != nil) != tt.wantError { + t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) + return + } + + if !tt.wantError && tt.check != nil { + tt.check(t, buf.String()) + } + }) + } +} + +func TestWriteKittyGraphicsEdgeCases(t *testing.T) { + tests := []struct { + name string + img image.Image + opts *kitty.Options + wantError bool + }{ + { + name: "zero size image", + img: image.NewRGBA(image.Rect(0, 0, 0, 0)), + opts: &kitty.Options{ + Transmission: kitty.Direct, + }, + wantError: false, + }, + { + name: "shared memory transmission", + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + opts: &kitty.Options{ + Transmission: kitty.SharedMemory, + }, + wantError: true, // Not implemented + }, + { + name: "file transmission without file path", + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + opts: &kitty.Options{ + Transmission: kitty.File, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := WriteKittyGraphics(&buf, tt.img, tt.opts) + + if (err != nil) != tt.wantError { + t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} From a5f1e9c70a05b6ec66992c124222efb38ba28f50 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 13 Jan 2025 15:51:41 +0300 Subject: [PATCH 5/6] feat(ansi): kitty: support encoding.TextMarshaler and encoding.TextUnmarshaler --- ansi/kitty/options.go | 103 +++++++++++++++++++++- ansi/kitty/options_test.go | 170 +++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 2 deletions(-) diff --git a/ansi/kitty/options.go b/ansi/kitty/options.go index 1f1ff0b1..9e0cfa5d 100644 --- a/ansi/kitty/options.go +++ b/ansi/kitty/options.go @@ -1,7 +1,15 @@ package kitty import ( + "encoding" "fmt" + "strconv" + "strings" +) + +var ( + _ encoding.TextMarshaler = Options{} + _ encoding.TextUnmarshaler = &Options{} ) // Options represents a Kitty Graphics Protocol options. @@ -45,7 +53,7 @@ type Options struct { Compression byte // Transmission (t=d) is the image transmission type. Can be [Direct], [File], - // [TempFile], or [SharedMemory]. + // [TempFile], or[SharedMemory]. Transmission byte // File is the file path to be used when the transmission type is [File]. @@ -101,7 +109,7 @@ type Options struct { // scaled to fit the number of rows. Rows int - // VirtualPlacement (V=0) whether to use virtual placement. This is used + // VirtualPlacement (U=0) whether to use virtual placement. This is used // with Unicode [Placeholder] to display images. VirtualPlacement bool @@ -258,3 +266,94 @@ func (o *Options) Options() (opts []string) { return } + +// String returns the string representation of the options. +func (o Options) String() string { + return strings.Join(o.Options(), ",") +} + +// MarshalText returns the string representation of the options. +func (o Options) MarshalText() ([]byte, error) { + return []byte(o.String()), nil +} + +// UnmarshalText parses the options from the given string. +func (o *Options) UnmarshalText(text []byte) error { + opts := strings.Split(string(text), ",") + for _, opt := range opts { + ps := strings.SplitN(opt, "=", 2) + if len(ps) != 2 || len(ps[1]) == 0 { + continue + } + + switch ps[0] { + case "a": + o.Action = ps[1][0] + case "o": + o.Compression = ps[1][0] + case "t": + o.Transmission = ps[1][0] + case "d": + d := ps[1][0] + if d >= 'A' && d <= 'Z' { + o.DeleteResources = true + d = d + ' ' // to lowercase + } + o.Delete = d + case "i", "q", "p", "I", "f", "s", "v", "S", "O", "m", "x", "y", "z", "w", "h", "X", "Y", "c", "r", "U", "P", "Q": + v, err := strconv.Atoi(ps[1]) + if err != nil { + continue + } + + switch ps[0] { + case "i": + o.ID = v + case "q": + o.Quite = byte(v) + case "p": + o.PlacementID = v + case "I": + o.Number = v + case "f": + o.Format = v + case "s": + o.ImageWidth = v + case "v": + o.ImageHeight = v + case "S": + o.Size = v + case "O": + o.Offset = v + case "m": + o.Chunk = v == 0 || v == 1 + case "x": + o.X = v + case "y": + o.Y = v + case "z": + o.Z = v + case "w": + o.Width = v + case "h": + o.Height = v + case "X": + o.OffsetX = v + case "Y": + o.OffsetY = v + case "c": + o.Columns = v + case "r": + o.Rows = v + case "U": + o.VirtualPlacement = v == 1 + case "P": + o.ParentID = v + case "Q": + o.ParentPlacementID = v + } + } + } + + return nil +} diff --git a/ansi/kitty/options_test.go b/ansi/kitty/options_test.go index 2bdef712..5d172943 100644 --- a/ansi/kitty/options_test.go +++ b/ansi/kitty/options_test.go @@ -198,6 +198,176 @@ func TestOptions_Validation(t *testing.T) { } } +func TestOptions_String(t *testing.T) { + tests := []struct { + name string + o Options + want string + }{ + { + name: "empty options", + o: Options{}, + want: "", + }, + { + name: "full options", + o: Options{ + Action: 'A', + Quite: 'Q', + Compression: 'C', + Transmission: 'T', + Delete: 'd', + DeleteResources: true, + ID: 123, + PlacementID: 456, + Number: 789, + Format: 1, + ImageWidth: 800, + ImageHeight: 600, + Size: 1024, + Offset: 10, + Chunk: true, + X: 100, + Y: 200, + Z: 300, + Width: 400, + Height: 500, + OffsetX: 50, + OffsetY: 60, + Columns: 4, + Rows: 3, + VirtualPlacement: true, + ParentID: 999, + ParentPlacementID: 888, + }, + want: "f=1,q=81,i=123,p=456,I=789,s=800,v=600,t=T,S=1024,O=10,U=1,P=999,Q=888,x=100,y=200,z=300,w=400,h=500,X=50,Y=60,c=4,r=3,d=D,a=A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.o.String(); got != tt.want { + t.Errorf("Options.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOptions_MarshalText(t *testing.T) { + tests := []struct { + name string + o Options + want []byte + wantErr bool + }{ + { + name: "marshal empty options", + o: Options{}, + want: []byte(""), + }, + { + name: "marshal with values", + o: Options{ + Action: 'A', + ID: 123, + Width: 400, + Height: 500, + Quite: 2, + }, + want: []byte("q=2,i=123,w=400,h=500,a=A"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.o.MarshalText() + if (err != nil) != tt.wantErr { + t.Errorf("Options.MarshalText() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Options.MarshalText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestOptions_UnmarshalText(t *testing.T) { + tests := []struct { + name string + text []byte + want Options + wantErr bool + }{ + { + name: "unmarshal empty", + text: []byte(""), + want: Options{}, + }, + { + name: "unmarshal basic options", + text: []byte("a=A,i=123,w=400,h=500"), + want: Options{ + Action: 'A', + ID: 123, + Width: 400, + Height: 500, + }, + }, + { + name: "unmarshal with invalid number", + text: []byte("i=abc"), + want: Options{}, + }, + { + name: "unmarshal with delete resources", + text: []byte("d=D"), + want: Options{ + Delete: 'd', + DeleteResources: true, + }, + }, + { + name: "unmarshal with boolean chunk", + text: []byte("m=1"), + want: Options{ + Chunk: true, + }, + }, + { + name: "unmarshal with virtual placement", + text: []byte("U=1"), + want: Options{ + VirtualPlacement: true, + }, + }, + { + name: "unmarshal with invalid format", + text: []byte("invalid=format"), + want: Options{}, + }, + { + name: "unmarshal with missing value", + text: []byte("a="), + want: Options{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var o Options + err := o.UnmarshalText(tt.text) + if (err != nil) != tt.wantErr { + t.Errorf("Options.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(o, tt.want) { + t.Errorf("Options.UnmarshalText() = %+v, want %+v", o, tt.want) + } + }) + } +} + // Helper functions func sortStrings(s []string) { From 8e01fc88ac17b8b56de20a57a9c545bcf63523e8 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 13 Jan 2025 16:51:57 +0300 Subject: [PATCH 6/6] chore: exclude tests --- ansi/graphics_test.go | 278 --------------------------- ansi/kitty/decoder_test.go | 252 ------------------------ ansi/kitty/encoder_test.go | 242 ----------------------- ansi/kitty/options_test.go | 384 ------------------------------------- 4 files changed, 1156 deletions(-) delete mode 100644 ansi/graphics_test.go delete mode 100644 ansi/kitty/decoder_test.go delete mode 100644 ansi/kitty/encoder_test.go delete mode 100644 ansi/kitty/options_test.go diff --git a/ansi/graphics_test.go b/ansi/graphics_test.go deleted file mode 100644 index 41df20f3..00000000 --- a/ansi/graphics_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package ansi - -import ( - "bytes" - "encoding/base64" - "image" - "image/color" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/charmbracelet/x/ansi/kitty" -) - -func TestKittyGraphics(t *testing.T) { - tests := []struct { - name string - payload []byte - opts []string - want string - }{ - { - name: "empty payload no options", - payload: []byte{}, - opts: nil, - want: "\x1b_G\x1b\\", - }, - { - name: "with payload no options", - payload: []byte("test"), - opts: nil, - want: "\x1b_G;test\x1b\\", - }, - { - name: "with payload and options", - payload: []byte("test"), - opts: []string{"a=t", "f=100"}, - want: "\x1b_Ga=t,f=100;test\x1b\\", - }, - { - name: "multiple options no payload", - payload: []byte{}, - opts: []string{"q=2", "C=1", "f=24"}, - want: "\x1b_Gq=2,C=1,f=24\x1b\\", - }, - { - name: "with special characters in payload", - payload: []byte("\x1b_G"), - opts: []string{"a=t"}, - want: "\x1b_Ga=t;\x1b_G\x1b\\", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := KittyGraphics(tt.payload, tt.opts...) - if got != tt.want { - t.Errorf("KittyGraphics() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestWriteKittyGraphics(t *testing.T) { - // Create a test image - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - img.Set(1, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255}) - img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(1, 1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) - - // Create large test image (larger than [kitty.MaxChunkSize] 4096 bytes) - imgLarge := image.NewRGBA(image.Rect(0, 0, 100, 100)) - for y := 0; y < 100; y++ { - for x := 0; x < 100; x++ { - imgLarge.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - } - } - - // Create a temporary test file - tmpDir := t.TempDir() - tmpFile := filepath.Join(tmpDir, "test-image") - if err := os.WriteFile(tmpFile, []byte("test image data"), 0o644); err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - img image.Image - opts *kitty.Options - wantError bool - check func(t *testing.T, output string) - }{ - { - name: "direct transmission", - img: img, - opts: &kitty.Options{ - Transmission: kitty.Direct, - Format: kitty.RGB, - }, - wantError: false, - check: func(t *testing.T, output string) { - if !strings.HasPrefix(output, "\x1b_G") { - t.Error("output should start with ESC sequence") - } - if !strings.HasSuffix(output, "\x1b\\") { - t.Error("output should end with ST sequence") - } - if !strings.Contains(output, "f=24") { - t.Error("output should contain format specification") - } - }, - }, - { - name: "chunked transmission", - img: imgLarge, - opts: &kitty.Options{ - Transmission: kitty.Direct, - Format: kitty.RGB, - Chunk: true, - }, - wantError: false, - check: func(t *testing.T, output string) { - chunks := strings.Split(output, "\x1b\\") - if len(chunks) < 2 { - t.Error("output should contain multiple chunks") - } - - chunks = chunks[:len(chunks)-1] // Remove last empty chunk - for i, chunk := range chunks { - if i == len(chunks)-1 { - if !strings.Contains(chunk, "m=0") { - t.Errorf("output should contain chunk end-of-data indicator for chunk %d %q", i, chunk) - } - } else { - if !strings.Contains(chunk, "m=1") { - t.Errorf("output should contain chunk indicator for chunk %d %q", i, chunk) - } - } - } - }, - }, - { - name: "file transmission", - img: img, - opts: &kitty.Options{ - Transmission: kitty.File, - File: tmpFile, - }, - wantError: false, - check: func(t *testing.T, output string) { - if !strings.Contains(output, base64.StdEncoding.EncodeToString([]byte(tmpFile))) { - t.Error("output should contain encoded file path") - } - }, - }, - { - name: "temp file transmission", - img: img, - opts: &kitty.Options{ - Transmission: kitty.TempFile, - }, - wantError: false, - check: func(t *testing.T, output string) { - output = strings.TrimPrefix(output, "\x1b_G") - output = strings.TrimSuffix(output, "\x1b\\") - payload := strings.Split(output, ";")[1] - fn, err := base64.StdEncoding.DecodeString(payload) - if err != nil { - t.Error("output should contain base64 encoded temp file path") - } - if !strings.Contains(string(fn), "tty-graphics-protocol") { - t.Error("output should contain temp file path") - } - if !strings.Contains(output, "t=t") { - t.Error("output should contain transmission specification") - } - }, - }, - { - name: "compression enabled", - img: img, - opts: &kitty.Options{ - Transmission: kitty.Direct, - Compression: kitty.Zlib, - }, - wantError: false, - check: func(t *testing.T, output string) { - if !strings.Contains(output, "o=z") { - t.Error("output should contain compression specification") - } - }, - }, - { - name: "invalid file path", - img: img, - opts: &kitty.Options{ - Transmission: kitty.File, - File: "/nonexistent/file", - }, - wantError: true, - check: nil, - }, - { - name: "nil options", - img: img, - opts: nil, - wantError: false, - check: func(t *testing.T, output string) { - if !strings.HasPrefix(output, "\x1b_G") { - t.Error("output should start with ESC sequence") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := WriteKittyGraphics(&buf, tt.img, tt.opts) - - if (err != nil) != tt.wantError { - t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) - return - } - - if !tt.wantError && tt.check != nil { - tt.check(t, buf.String()) - } - }) - } -} - -func TestWriteKittyGraphicsEdgeCases(t *testing.T) { - tests := []struct { - name string - img image.Image - opts *kitty.Options - wantError bool - }{ - { - name: "zero size image", - img: image.NewRGBA(image.Rect(0, 0, 0, 0)), - opts: &kitty.Options{ - Transmission: kitty.Direct, - }, - wantError: false, - }, - { - name: "shared memory transmission", - img: image.NewRGBA(image.Rect(0, 0, 1, 1)), - opts: &kitty.Options{ - Transmission: kitty.SharedMemory, - }, - wantError: true, // Not implemented - }, - { - name: "file transmission without file path", - img: image.NewRGBA(image.Rect(0, 0, 1, 1)), - opts: &kitty.Options{ - Transmission: kitty.File, - }, - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := WriteKittyGraphics(&buf, tt.img, tt.opts) - - if (err != nil) != tt.wantError { - t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) - } - }) - } -} diff --git a/ansi/kitty/decoder_test.go b/ansi/kitty/decoder_test.go deleted file mode 100644 index 61972179..00000000 --- a/ansi/kitty/decoder_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package kitty - -import ( - "bytes" - "compress/zlib" - "image" - "image/color" - "image/png" - "reflect" - "testing" -) - -func TestDecoder_Decode(t *testing.T) { - // Helper function to create compressed data - compress := func(data []byte) []byte { - var buf bytes.Buffer - w := zlib.NewWriter(&buf) - w.Write(data) - w.Close() - return buf.Bytes() - } - - tests := []struct { - name string - decoder Decoder - input []byte - want image.Image - wantErr bool - }{ - { - name: "RGBA format 2x2", - decoder: Decoder{ - Format: RGBA, - Width: 2, - Height: 2, - }, - input: []byte{ - 255, 0, 0, 255, // Red pixel - 0, 0, 255, 255, // Blue pixel - 0, 0, 255, 255, // Blue pixel - 255, 0, 0, 255, // Red pixel - }, - want: func() image.Image { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - return img - }(), - wantErr: false, - }, - { - name: "RGB format 2x2", - decoder: Decoder{ - Format: RGB, - Width: 2, - Height: 2, - }, - input: []byte{ - 255, 0, 0, // Red pixel - 0, 0, 255, // Blue pixel - 0, 0, 255, // Blue pixel - 255, 0, 0, // Red pixel - }, - want: func() image.Image { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - return img - }(), - wantErr: false, - }, - { - name: "RGBA with compression", - decoder: Decoder{ - Format: RGBA, - Width: 2, - Height: 2, - Decompress: true, - }, - input: compress([]byte{ - 255, 0, 0, 255, - 0, 0, 255, 255, - 0, 0, 255, 255, - 255, 0, 0, 255, - }), - want: func() image.Image { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) - img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - return img - }(), - wantErr: false, - }, - { - name: "PNG format", - decoder: Decoder{ - Format: PNG, - // Width and height are embedded and inferred from the PNG data - }, - input: func() []byte { - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - var buf bytes.Buffer - png.Encode(&buf, img) - return buf.Bytes() - }(), - want: func() image.Image { - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - return img - }(), - wantErr: false, - }, - { - name: "invalid format", - decoder: Decoder{ - Format: 999, - Width: 2, - Height: 2, - }, - input: []byte{0, 0, 0}, - want: nil, - wantErr: true, - }, - { - name: "incomplete RGBA data", - decoder: Decoder{ - Format: RGBA, - Width: 2, - Height: 2, - }, - input: []byte{255, 0, 0}, // Incomplete pixel data - want: nil, - wantErr: true, - }, - { - name: "invalid compressed data", - decoder: Decoder{ - Format: RGBA, - Width: 2, - Height: 2, - Decompress: true, - }, - input: []byte{1, 2, 3}, // Invalid zlib data - want: nil, - wantErr: true, - }, - { - name: "default format (RGBA)", - decoder: Decoder{ - Width: 1, - Height: 1, - }, - input: []byte{255, 0, 0, 255}, - want: func() image.Image { - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - return img - }(), - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.decoder.Decode(bytes.NewReader(tt.input)) - - if (err != nil) != tt.wantErr { - t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if tt.wantErr { - return - } - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Decode() output mismatch") - if bounds := got.Bounds(); bounds != tt.want.Bounds() { - t.Errorf("bounds got %v, want %v", bounds, tt.want.Bounds()) - } - - // Compare pixels - bounds := got.Bounds() - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - gotColor := got.At(x, y) - wantColor := tt.want.At(x, y) - if !reflect.DeepEqual(gotColor, wantColor) { - t.Errorf("pixel at (%d,%d) = %v, want %v", x, y, gotColor, wantColor) - } - } - } - } - }) - } -} - -func TestDecoder_DecodeEdgeCases(t *testing.T) { - tests := []struct { - name string - decoder Decoder - input []byte - wantErr bool - }{ - { - name: "zero dimensions", - decoder: Decoder{ - Format: RGBA, - Width: 0, - Height: 0, - }, - input: []byte{}, - wantErr: false, - }, - { - name: "negative width", - decoder: Decoder{ - Format: RGBA, - Width: -1, - Height: 1, - }, - input: []byte{255, 0, 0, 255}, - wantErr: false, // The image package handles this gracefully - }, - { - name: "very large dimensions", - decoder: Decoder{ - Format: RGBA, - Width: 1, - Height: 1000000, // Very large height - }, - input: []byte{255, 0, 0, 255}, // Not enough data - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := tt.decoder.Decode(bytes.NewReader(tt.input)) - if (err != nil) != tt.wantErr { - t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/ansi/kitty/encoder_test.go b/ansi/kitty/encoder_test.go deleted file mode 100644 index 09154719..00000000 --- a/ansi/kitty/encoder_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package kitty - -import ( - "bytes" - "compress/zlib" - "image" - "image/color" - "io" - "testing" -) - -// taken from "image/png" package -const pngHeader = "\x89PNG\r\n\x1a\n" - -// testImage creates a simple test image with a red and blue pattern -func testImage() *image.RGBA { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red - img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue - img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue - img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red - return img -} - -func TestEncoder_Encode(t *testing.T) { - tests := []struct { - name string - encoder Encoder - img image.Image - wantErr bool - verify func([]byte) error - }{ - { - name: "nil image", - encoder: Encoder{ - Format: RGBA, - }, - img: nil, - wantErr: false, - verify: func(got []byte) error { - if len(got) != 0 { - t.Errorf("expected empty output for nil image, got %d bytes", len(got)) - } - return nil - }, - }, - { - name: "RGBA format", - encoder: Encoder{ - Format: RGBA, - }, - img: testImage(), - wantErr: false, - verify: func(got []byte) error { - expected := []byte{ - 255, 0, 0, 255, // Red pixel - 0, 0, 255, 255, // Blue pixel - 0, 0, 255, 255, // Blue pixel - 255, 0, 0, 255, // Red pixel - } - if !bytes.Equal(got, expected) { - t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) - } - return nil - }, - }, - { - name: "RGB format", - encoder: Encoder{ - Format: RGB, - }, - img: testImage(), - wantErr: false, - verify: func(got []byte) error { - expected := []byte{ - 255, 0, 0, // Red pixel - 0, 0, 255, // Blue pixel - 0, 0, 255, // Blue pixel - 255, 0, 0, // Red pixel - } - if !bytes.Equal(got, expected) { - t.Errorf("unexpected RGB output\ngot: %v\nwant: %v", got, expected) - } - return nil - }, - }, - { - name: "PNG format", - encoder: Encoder{ - Format: PNG, - }, - img: testImage(), - wantErr: false, - verify: func(got []byte) error { - // Verify PNG header - // if len(got) < 8 || !bytes.Equal(got[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) { - if len(got) < 8 || !bytes.Equal(got[:8], []byte(pngHeader)) { - t.Error("invalid PNG header") - } - return nil - }, - }, - { - name: "invalid format", - encoder: Encoder{ - Format: 999, // Invalid format - }, - img: testImage(), - wantErr: true, - verify: nil, - }, - { - name: "RGBA with compression", - encoder: Encoder{ - Format: RGBA, - Compress: true, - }, - img: testImage(), - wantErr: false, - verify: func(got []byte) error { - // Decompress the data - r, err := zlib.NewReader(bytes.NewReader(got)) - if err != nil { - return err - } - defer r.Close() - - decompressed, err := io.ReadAll(r) - if err != nil { - return err - } - - expected := []byte{ - 255, 0, 0, 255, // Red pixel - 0, 0, 255, 255, // Blue pixel - 0, 0, 255, 255, // Blue pixel - 255, 0, 0, 255, // Red pixel - } - if !bytes.Equal(decompressed, expected) { - t.Errorf("unexpected decompressed output\ngot: %v\nwant: %v", decompressed, expected) - } - return nil - }, - }, - { - name: "zero format defaults to RGBA", - encoder: Encoder{ - Format: 0, - }, - img: testImage(), - wantErr: false, - verify: func(got []byte) error { - expected := []byte{ - 255, 0, 0, 255, // Red pixel - 0, 0, 255, 255, // Blue pixel - 0, 0, 255, 255, // Blue pixel - 255, 0, 0, 255, // Red pixel - } - if !bytes.Equal(got, expected) { - t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) - } - return nil - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - err := tt.encoder.Encode(&buf, tt.img) - - if (err != nil) != tt.wantErr { - t.Errorf("Encode() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr && tt.verify != nil { - if err := tt.verify(buf.Bytes()); err != nil { - t.Errorf("verification failed: %v", err) - } - } - }) - } -} - -func TestEncoder_EncodeWithDifferentImageTypes(t *testing.T) { - // Create different image types for testing - rgba := image.NewRGBA(image.Rect(0, 0, 1, 1)) - rgba.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - - gray := image.NewGray(image.Rect(0, 0, 1, 1)) - gray.Set(0, 0, color.Gray{Y: 128}) - - tests := []struct { - name string - img image.Image - format int - wantLen int - }{ - { - name: "RGBA image to RGBA format", - img: rgba, - format: RGBA, - wantLen: 4, // 4 bytes per pixel - }, - { - name: "Gray image to RGBA format", - img: gray, - format: RGBA, - wantLen: 4, // 4 bytes per pixel - }, - { - name: "RGBA image to RGB format", - img: rgba, - format: RGB, - wantLen: 3, // 3 bytes per pixel - }, - { - name: "Gray image to RGB format", - img: gray, - format: RGB, - wantLen: 3, // 3 bytes per pixel - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - enc := Encoder{Format: tt.format} - - err := enc.Encode(&buf, tt.img) - if err != nil { - t.Errorf("Encode() error = %v", err) - return - } - - if got := buf.Len(); got != tt.wantLen { - t.Errorf("Encode() output length = %v, want %v", got, tt.wantLen) - } - }) - } -} diff --git a/ansi/kitty/options_test.go b/ansi/kitty/options_test.go deleted file mode 100644 index 5d172943..00000000 --- a/ansi/kitty/options_test.go +++ /dev/null @@ -1,384 +0,0 @@ -package kitty - -import ( - "reflect" - "sort" - "testing" -) - -func TestOptions_Options(t *testing.T) { - tests := []struct { - name string - options Options - expected []string - }{ - { - name: "default options", - options: Options{}, - expected: []string{}, // Default values don't generate options - }, - { - name: "basic transmission options", - options: Options{ - Format: PNG, - ID: 1, - Action: TransmitAndPut, - }, - expected: []string{ - "f=100", - "i=1", - "a=T", - }, - }, - { - name: "display options", - options: Options{ - X: 100, - Y: 200, - Z: 3, - Width: 400, - Height: 300, - }, - expected: []string{ - "x=100", - "y=200", - "z=3", - "w=400", - "h=300", - }, - }, - { - name: "compression and chunking", - options: Options{ - Compression: Zlib, - Chunk: true, - Size: 1024, - }, - expected: []string{ - "S=1024", - "o=z", - }, - }, - { - name: "delete options", - options: Options{ - Delete: DeleteID, - DeleteResources: true, - }, - expected: []string{ - "d=I", // Uppercase due to DeleteResources being true - }, - }, - { - name: "virtual placement", - options: Options{ - VirtualPlacement: true, - ParentID: 5, - ParentPlacementID: 2, - }, - expected: []string{ - "U=1", - "P=5", - "Q=2", - }, - }, - { - name: "cell positioning", - options: Options{ - OffsetX: 10, - OffsetY: 20, - Columns: 80, - Rows: 24, - }, - expected: []string{ - "X=10", - "Y=20", - "c=80", - "r=24", - }, - }, - { - name: "transmission details", - options: Options{ - Transmission: File, - File: "/tmp/image.png", - Offset: 100, - Number: 2, - PlacementID: 3, - }, - expected: []string{ - "p=3", - "I=2", - "t=f", - "O=100", - }, - }, - { - name: "quiet mode and format", - options: Options{ - Quite: 2, - Format: RGB, - }, - expected: []string{ - "f=24", - "q=2", - }, - }, - { - name: "all zero values", - options: Options{ - Format: 0, - Action: 0, - Delete: 0, - }, - expected: []string{}, // Should use defaults and not generate options - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.options.Options() - - // Sort both slices to ensure consistent comparison - sortStrings(got) - sortStrings(tt.expected) - - if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("Options.Options() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestOptions_Validation(t *testing.T) { - tests := []struct { - name string - options Options - check func([]string) bool - }{ - { - name: "format validation", - options: Options{ - Format: 999, // Invalid format - }, - check: func(opts []string) bool { - // Should still output the format even if invalid - return containsOption(opts, "f=999") - }, - }, - { - name: "delete with resources", - options: Options{ - Delete: DeleteID, - DeleteResources: true, - }, - check: func(opts []string) bool { - // Should be uppercase when DeleteResources is true - return containsOption(opts, "d=I") - }, - }, - { - name: "transmission with file", - options: Options{ - File: "/tmp/test.png", - }, - check: func(opts []string) bool { - return containsOption(opts, "t=f") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.options.Options() - if !tt.check(got) { - t.Errorf("Options validation failed for %s: %v", tt.name, got) - } - }) - } -} - -func TestOptions_String(t *testing.T) { - tests := []struct { - name string - o Options - want string - }{ - { - name: "empty options", - o: Options{}, - want: "", - }, - { - name: "full options", - o: Options{ - Action: 'A', - Quite: 'Q', - Compression: 'C', - Transmission: 'T', - Delete: 'd', - DeleteResources: true, - ID: 123, - PlacementID: 456, - Number: 789, - Format: 1, - ImageWidth: 800, - ImageHeight: 600, - Size: 1024, - Offset: 10, - Chunk: true, - X: 100, - Y: 200, - Z: 300, - Width: 400, - Height: 500, - OffsetX: 50, - OffsetY: 60, - Columns: 4, - Rows: 3, - VirtualPlacement: true, - ParentID: 999, - ParentPlacementID: 888, - }, - want: "f=1,q=81,i=123,p=456,I=789,s=800,v=600,t=T,S=1024,O=10,U=1,P=999,Q=888,x=100,y=200,z=300,w=400,h=500,X=50,Y=60,c=4,r=3,d=D,a=A", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.o.String(); got != tt.want { - t.Errorf("Options.String() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestOptions_MarshalText(t *testing.T) { - tests := []struct { - name string - o Options - want []byte - wantErr bool - }{ - { - name: "marshal empty options", - o: Options{}, - want: []byte(""), - }, - { - name: "marshal with values", - o: Options{ - Action: 'A', - ID: 123, - Width: 400, - Height: 500, - Quite: 2, - }, - want: []byte("q=2,i=123,w=400,h=500,a=A"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.o.MarshalText() - if (err != nil) != tt.wantErr { - t.Errorf("Options.MarshalText() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Options.MarshalText() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestOptions_UnmarshalText(t *testing.T) { - tests := []struct { - name string - text []byte - want Options - wantErr bool - }{ - { - name: "unmarshal empty", - text: []byte(""), - want: Options{}, - }, - { - name: "unmarshal basic options", - text: []byte("a=A,i=123,w=400,h=500"), - want: Options{ - Action: 'A', - ID: 123, - Width: 400, - Height: 500, - }, - }, - { - name: "unmarshal with invalid number", - text: []byte("i=abc"), - want: Options{}, - }, - { - name: "unmarshal with delete resources", - text: []byte("d=D"), - want: Options{ - Delete: 'd', - DeleteResources: true, - }, - }, - { - name: "unmarshal with boolean chunk", - text: []byte("m=1"), - want: Options{ - Chunk: true, - }, - }, - { - name: "unmarshal with virtual placement", - text: []byte("U=1"), - want: Options{ - VirtualPlacement: true, - }, - }, - { - name: "unmarshal with invalid format", - text: []byte("invalid=format"), - want: Options{}, - }, - { - name: "unmarshal with missing value", - text: []byte("a="), - want: Options{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var o Options - err := o.UnmarshalText(tt.text) - if (err != nil) != tt.wantErr { - t.Errorf("Options.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(o, tt.want) { - t.Errorf("Options.UnmarshalText() = %+v, want %+v", o, tt.want) - } - }) - } -} - -// Helper functions - -func sortStrings(s []string) { - sort.Strings(s) -} - -func containsOption(opts []string, target string) bool { - for _, opt := range opts { - if opt == target { - return true - } - } - return false -}