Skip to content

Commit

Permalink
feat(ansi): iterm2: add iTerm2 Inline Image Protocol (#324)
Browse files Browse the repository at this point in the history
This implements the iTerm2 Inline Image Protocol. The protocol allows
applications to display images inline in the terminal. The protocol is
documented at https://iterm2.com/documentation-images.html.
  • Loading branch information
aymanbagabas authored Jan 13, 2025
1 parent d87966b commit 1814328
Show file tree
Hide file tree
Showing 4 changed files with 413 additions and 0 deletions.
18 changes: 18 additions & 0 deletions ansi/iterm2.go
Original file line number Diff line number Diff line change
@@ -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"
}
130 changes: 130 additions & 0 deletions ansi/iterm2/file.go
Original file line number Diff line number Diff line change
@@ -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"
}
175 changes: 175 additions & 0 deletions ansi/iterm2/file_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 1814328

Please sign in to comment.