diff --git a/ansi/iterm2.go b/ansi/iterm2.go new file mode 100644 index 00000000..0ecb336d --- /dev/null +++ b/ansi/iterm2.go @@ -0,0 +1,18 @@ +package ansi + +import "fmt" + +// ITerm2 returns a sequence that uses the iTerm2 proprietary protocol. Use the +// iterm2 package for a more convenient API. +// +// OSC 1337 ; key = value ST +// +// Example: +// +// ITerm2(iterm2.File{...}) +// +// See https://iterm2.com/documentation-escape-codes.html +// See https://iterm2.com/documentation-images.html +func ITerm2(data any) string { + return "\x1b]1337;" + fmt.Sprint(data) + "\x07" +} diff --git a/ansi/iterm2/file.go b/ansi/iterm2/file.go new file mode 100644 index 00000000..65a14858 --- /dev/null +++ b/ansi/iterm2/file.go @@ -0,0 +1,130 @@ +package iterm2 + +import ( + "strconv" + "strings" +) + +// Auto is a constant that represents the "auto" value. +const Auto = "auto" + +// Cells returns a string that represents the number of cells. This is simply a +// wrapper around strconv.Itoa. +func Cells(n int) string { + return strconv.Itoa(n) +} + +// Pixels returns a string that represents the number of pixels. +func Pixels(n int) string { + return strconv.Itoa(n) + "px" +} + +// Percent returns a string that represents the percentage. +func Percent(n int) string { + return strconv.Itoa(n) + "%" +} + +// file represents the optional arguments for the iTerm2 Inline Image Protocol. +// +// See https://iterm2.com/documentation-images.html +type file struct { + // Name is the name of the file. Defaults to "Unnamed file" if empty. + Name string + // Size is the file size in bytes. Used for progress indication. This is + // optional. + Size int64 + // Width is the width of the image. This can be specified by a number + // followed by by a unit or "auto". The unit can be none, "px" or "%". None + // means the number is in cells. Defaults to "auto" if empty. + // For convenience, the [Pixels], [Cells] and [Percent] functions and + // [Auto] can be used. + Width string + // Height is the height of the image. This can be specified by a number + // followed by by a unit or "auto". The unit can be none, "px" or "%". None + // means the number is in cells. Defaults to "auto" if empty. + // For convenience, the [Pixels], [Cells] and [Percent] functions and + // [Auto] can be used. + Height string + // IgnoreAspectRatio is a flag that indicates that the image should be + // stretched to fit the specified width and height. Defaults to false + // meaning the aspect ratio is preserved. + IgnoreAspectRatio bool + // Inline is a flag that indicates that the image should be displayed + // inline. Otherwise, it is downloaded to the Downloads folder with no + // visual representation in the terminal. Defaults to false. + Inline bool + // DoNotMoveCursor is a flag that indicates that the cursor should not be + // moved after displaying the image. This is an extension introduced by + // WezTerm and might not work on all terminals supporting the iTerm2 + // protocol. Defaults to false. + DoNotMoveCursor bool + // Content is the base64 encoded data of the file. + Content []byte +} + +// String implements fmt.Stringer. +func (f file) String() string { + var opts []string + if f.Name != "" { + opts = append(opts, "name="+f.Name) + } + if f.Size != 0 { + opts = append(opts, "size="+strconv.FormatInt(f.Size, 10)) + } + if f.Width != "" { + opts = append(opts, "width="+f.Width) + } + if f.Height != "" { + opts = append(opts, "height="+f.Height) + } + if f.IgnoreAspectRatio { + opts = append(opts, "preserveAspectRatio=0") + } + if f.Inline { + opts = append(opts, "inline=1") + } + if f.DoNotMoveCursor { + opts = append(opts, "doNotMoveCursor=1") + } + return strings.Join(opts, ";") +} + +// File represents the optional arguments for the iTerm2 Inline Image Protocol. +type File file + +// String implements fmt.Stringer. +func (f File) String() string { + var s strings.Builder + s.WriteString("File=") + s.WriteString(file(f).String()) + if len(f.Content) > 0 { + s.WriteString(":") + s.Write(f.Content) + } + + return s.String() +} + +// MultipartFile represents the optional arguments for the iTerm2 Inline Image Protocol. +type MultipartFile file + +// String implements fmt.Stringer. +func (f MultipartFile) String() string { + return "MultipartFile=" + file(f).String() +} + +// FilePart represents the optional arguments for the iTerm2 Inline Image Protocol. +type FilePart file + +// String implements fmt.Stringer. +func (f FilePart) String() string { + return "FilePart=" + string(f.Content) +} + +// FileEnd represents the optional arguments for the iTerm2 Inline Image Protocol. +type FileEnd struct{} + +// String implements fmt.Stringer. +func (f FileEnd) String() string { + return "FileEnd" +} diff --git a/ansi/iterm2/file_test.go b/ansi/iterm2/file_test.go new file mode 100644 index 00000000..e5c29c9b --- /dev/null +++ b/ansi/iterm2/file_test.go @@ -0,0 +1,175 @@ +package iterm2 + +import ( + "encoding/base64" + "testing" +) + +func TestCells(t *testing.T) { + tests := []struct { + input int + want string + }{ + {0, "0"}, + {10, "10"}, + {-5, "-5"}, + {100, "100"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := Cells(tt.input); got != tt.want { + t.Errorf("Cells(%d) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestPixels(t *testing.T) { + tests := []struct { + input int + want string + }{ + {0, "0px"}, + {10, "10px"}, + {-5, "-5px"}, + {100, "100px"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := Pixels(tt.input); got != tt.want { + t.Errorf("Pixels(%d) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestPercent(t *testing.T) { + tests := []struct { + input int + want string + }{ + {0, "0%"}, + {10, "10%"}, + {-5, "-5%"}, + {100, "100%"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := Percent(tt.input); got != tt.want { + t.Errorf("Percent(%d) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestFile_String(t *testing.T) { + sampleContent := []byte("test-content") + tests := []struct { + name string + file file + want string + }{ + { + name: "empty file", + file: file{}, + want: "", + }, + { + name: "basic file", + file: file{ + Name: "test.png", + Size: 1024, + }, + want: "name=test.png;size=1024", + }, + { + name: "file with dimensions", + file: file{ + Name: "test.png", + Width: "100px", + Height: "auto", + }, + want: "name=test.png;width=100px;height=auto", + }, + { + name: "file with all options", + file: file{ + Name: "test.png", + Size: 1024, + Width: "100px", + Height: "50%", + IgnoreAspectRatio: true, + Inline: true, + DoNotMoveCursor: true, + Content: sampleContent, + }, + want: "name=test.png;size=1024;width=100px;height=50%;preserveAspectRatio=0;inline=1;doNotMoveCursor=1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.file.String(); got != tt.want { + t.Errorf("file.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFile_String_WithContent(t *testing.T) { + sampleContent := []byte("test-content") + encodedContent := base64.StdEncoding.EncodeToString(sampleContent) + + f := File{ + Name: "test.png", + Content: []byte(encodedContent), + } + + want := "File=name=test.png:" + encodedContent + if got := f.String(); got != want { + t.Errorf("File.String() = %v, want %v", got, want) + } +} + +func TestMultipartFile_String(t *testing.T) { + f := MultipartFile{ + Name: "test.png", + Size: 1024, + Width: "100px", + Height: "50%", + } + + want := "MultipartFile=name=test.png;size=1024;width=100px;height=50%" + if got := f.String(); got != want { + t.Errorf("MultipartFile.String() = %v, want %v", got, want) + } +} + +func TestFilePart_String(t *testing.T) { + sampleContent := []byte("test-content") + f := FilePart{ + Content: sampleContent, + } + + want := "FilePart=" + string(sampleContent) + if got := f.String(); got != want { + t.Errorf("FilePart.String() = %v, want %v", got, want) + } +} + +func TestFileEnd_String(t *testing.T) { + f := FileEnd{} + want := "FileEnd" + if got := f.String(); got != want { + t.Errorf("FileEnd.String() = %v, want %v", got, want) + } +} + +func TestAuto_Constant(t *testing.T) { + if Auto != "auto" { + t.Errorf("Auto constant = %v, want 'auto'", Auto) + } +} diff --git a/ansi/iterm2_test.go b/ansi/iterm2_test.go new file mode 100644 index 00000000..4da20611 --- /dev/null +++ b/ansi/iterm2_test.go @@ -0,0 +1,90 @@ +package ansi + +import ( + "encoding/base64" + "testing" + + "github.com/charmbracelet/x/ansi/iterm2" +) + +func TestITerm2(t *testing.T) { + tests := []struct { + name string + data any + want string + }{ + { + name: "empty file", + data: iterm2.File{}, + want: "\x1b]1337;File=\x07", + }, + { + name: "basic file", + data: iterm2.File{ + Name: "test.png", + Size: 1024, + }, + want: "\x1b]1337;File=name=test.png;size=1024\x07", + }, + { + name: "file with dimensions", + data: iterm2.File{ + Name: "test.png", + Width: iterm2.Pixels(100), + Height: iterm2.Auto, + }, + want: "\x1b]1337;File=name=test.png;width=100px;height=auto\x07", + }, + { + name: "file with all options", + data: iterm2.File{ + Name: "test.png", + Size: 1024, + Width: iterm2.Cells(100), + Height: iterm2.Percent(50), + IgnoreAspectRatio: true, + Inline: true, + DoNotMoveCursor: true, + }, + want: "\x1b]1337;File=name=test.png;size=1024;width=100;height=50%;preserveAspectRatio=0;inline=1;doNotMoveCursor=1\x07", + }, + { + name: "file with content", + data: iterm2.File{ + Name: "test.png", + Content: []byte(base64.StdEncoding.EncodeToString([]byte("test-content"))), + }, + want: "\x1b]1337;File=name=test.png:dGVzdC1jb250ZW50\x07", + }, + { + name: "multipart file", + data: iterm2.MultipartFile{ + Name: "test.png", + Size: 1024, + Width: iterm2.Pixels(100), + Height: iterm2.Percent(50), + }, + want: "\x1b]1337;MultipartFile=name=test.png;size=1024;width=100px;height=50%\x07", + }, + { + name: "file part", + data: iterm2.FilePart{ + Content: []byte("part-content"), + }, + want: "\x1b]1337;FilePart=part-content\x07", + }, + { + name: "file end", + data: iterm2.FileEnd{}, + want: "\x1b]1337;FileEnd\x07", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ITerm2(tt.data); got != tt.want { + t.Errorf("ITerm2() = %v, want %v", got, tt.want) + } + }) + } +}