diff --git a/clib/sixelconv.go b/clib/sixelconv.go new file mode 100644 index 0000000000000000000000000000000000000000..29e8053a0af791fd6d09661e0eb2ee7aa2f444b6 --- /dev/null +++ b/clib/sixelconv.go @@ -0,0 +1,31 @@ +package clib + +import ( + "bytes" + "image/png" + + "github.com/mattn/go-sixel" +) + +// EncodePNGToSixel converts PNG bytes to Sixel format +// Returns Sixel sequence and row count needed for terminal spacing +func EncodePNGToSixel(pngData []byte, cellHeightPx int) (string, int, error) { + // Decode PNG + img, err := png.Decode(bytes.NewReader(pngData)) + if err != nil { + return "", 0, err + } + + // Encode to Sixel + var buf bytes.Buffer + enc := sixel.NewEncoder(&buf) + if err := enc.Encode(img); err != nil { + return "", 0, err + } + + // Calculate rows: image height / cell height + bounds := img.Bounds() + rows := (bounds.Dy() + cellHeightPx - 1) / cellHeightPx + + return buf.String(), rows, nil +} diff --git a/clib/sixelconv_test.go b/clib/sixelconv_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6a36aae6cb90a244b8388ac9aa314c8d9ffc188e --- /dev/null +++ b/clib/sixelconv_test.go @@ -0,0 +1,29 @@ +package clib + +import ( + "encoding/base64" + "testing" +) + +// 1x1 red PNG +const testPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + +func TestEncodePNGToSixel(t *testing.T) { + pngData, err := base64.StdEncoding.DecodeString(testPNG) + if err != nil { + t.Fatal(err) + } + + sixel, rows, err := EncodePNGToSixel(pngData, 18) + if err != nil { + t.Fatalf("Failed to encode: %v", err) + } + + if rows < 1 { + t.Errorf("Expected rows >= 1, got %d", rows) + } + + if len(sixel) == 0 { + t.Error("Expected non-empty Sixel output") + } +} diff --git a/docs/docs/Features/Images.md b/docs/docs/Features/Images.md index e2bef29724ac73c666f4e817c73d53acc828016c..38e23b964b7e3e1893db07e0187498cbce212259 100644 --- a/docs/docs/Features/Images.md +++ b/docs/docs/Features/Images.md @@ -17,6 +17,16 @@ Full support is provided for terminals implementing the Kitty Graphics Protocol. - [Wayst](https://github.com/91861/wayst) - [Konsole](https://konsole.kde.org/) +### 🖼️ Sixel + +Support for the Sixel graphics format, a bitmap protocol widely supported across terminal emulators and multiplexers. + +**Supported Terminals:** +- [Foot](https://codeberg.org/dnkl/foot) +- [mlterm](https://github.com/arakiken/mlterm) +- [Zellij](https://zellij.dev/) (multiplexer — works on top of any terminal) +- Any terminal with `TERM` containing `xterm` and `SIXEL=1` environment variable set + ### 🖼️ iTerm2 Inline Images Native support is included for the iTerm2 inline image protocol. diff --git a/go.mod b/go.mod index d796e2d4d4b3f266ae79f918ba32bf4c74228339..4f11c7eb80d51c04ad1b5ed0dc6849b1e0bea2e8 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/knadh/go-pop3 v1.0.2 + github.com/mattn/go-sixel v0.0.9 github.com/yuin/goldmark v1.8.2 github.com/yuin/gopher-lua v1.1.2 github.com/zalando/go-keyring v0.2.8 @@ -50,6 +51,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/soniakeys/quant v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.4.0 // indirect diff --git a/go.sum b/go.sum index 7e6ef4f395dc97036fdf0acbf9ba4a5857e3c3b6..1772e723b8465698885056228aace1d0b1bf2d10 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sixel v0.0.9 h1:ncx/rVU35Ut7/6gpVk4deC4/Wp2js9fDKmFmWnzmGoY= +github.com/mattn/go-sixel v0.0.9/go.mod h1:mfichvavqIDFW14LGU24ux/UZ/wF0/hG+4pUWOWrQgM= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -93,6 +95,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/view/html.go b/view/html.go index 186cd036190283a61932cdfca15632f02c09c7f6..ba3846e6dbc1ddfbde393d8225967c56dd6c0405 100644 --- a/view/html.go +++ b/view/html.go @@ -235,14 +235,36 @@ func konsoleSupported() bool { return false } +func zellijSupported() bool { + return os.Getenv("ZELLIJ") != "" || os.Getenv("ZELLIJ_SESSION_NAME") != "" +} + +func sixelSupported() bool { + // Zellij always supports Sixel + if zellijSupported() { + return true + } + + // Native Sixel terminals + term := strings.ToLower(os.Getenv("TERM")) + return strings.Contains(term, "mlterm") || + strings.Contains(term, "foot") || + (strings.Contains(term, "xterm") && os.Getenv("SIXEL") == "1") +} + // ImageProtocolSupported checks if any supported image protocol terminal is detected. func ImageProtocolSupported() bool { return imageProtocolSupported() } +// SixelSupported returns true if the terminal uses the Sixel graphics protocol. +func SixelSupported() bool { + return sixelSupported() +} + // imageProtocolSupported checks if any supported image protocol terminal is detected. func imageProtocolSupported() bool { - return kittySupported() || ghosttySupported() || iterm2Supported() || + return sixelSupported() || kittySupported() || ghosttySupported() || iterm2Supported() || weztermSupported() || waystSupported() || warpSupported() || konsoleSupported() } @@ -422,12 +444,54 @@ func iterm2InlineImage(payload string) string { return result } +// sixelInlineImage returns Sixel escape sequence + newline placeholders +func sixelInlineImage(base64PNG string) string { + data, err := base64.StdEncoding.DecodeString(base64PNG) + if err != nil { + return "" + } + + cellHeight := getTerminalCellSize() + sixel, rows, err := clib.EncodePNGToSixel(data, cellHeight) + if err != nil { + debugImageProtocol("Sixel encoding failed: %v", err) + return "" + } + + debugImageProtocol("Sixel: encoded %d bytes, %d rows", len(sixel), rows) + + // Sixel sequences don't auto-advance cursor + // Add newlines to preserve layout + return sixel + strings.Repeat("\n", rows) +} + +// sixelImageEscapeOnly returns raw Sixel for out-of-band rendering +func sixelImageEscapeOnly(base64PNG string) string { + data, err := base64.StdEncoding.DecodeString(base64PNG) + if err != nil { + return "" + } + + cellHeight := getTerminalCellSize() + sixel, _, err := clib.EncodePNGToSixel(data, cellHeight) + if err != nil { + return "" + } + + return sixel +} + // renderInlineImage renders an image using the appropriate protocol for the detected terminal func renderInlineImage(payload string) string { if payload == "" { return "" } + // Priority: Sixel in multiplexers overrides native protocols + if sixelSupported() { + return sixelInlineImage(payload) + } + if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() { // These terminals use the Kitty graphics protocol return kittyInlineImage(payload) @@ -519,6 +583,27 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ... col = screenCol[0] } + // Priority: Sixel in multiplexers + if sixelSupported() { + debugImageProtocol("Sixel: RenderImageToStdout row=%d col=%d base64len=%d", screenRow, col, len(placement.Base64)) + + // Encode once, reuse cached Sixel on subsequent renders (like Kitty's upload-once pattern) + if placement.SixelEncoded == "" { + placement.SixelEncoded = sixelImageEscapeOnly(placement.Base64) + if placement.SixelEncoded == "" { + debugImageProtocol("Sixel: sixelImageEscapeOnly returned empty") + return + } + } + + debugImageProtocol("Sixel: rendering %d bytes at row=%d col=%d", len(placement.SixelEncoded), screenRow+1, col) + // Position cursor + render Sixel + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", + screenRow+1, col, placement.SixelEncoded) + os.Stdout.Sync() + return + } + useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() useIterm2 := iterm2Supported() || warpSupported() @@ -564,11 +649,12 @@ type InlineImage struct { // line in the email body. Images are rendered directly to stdout (bypassing // bubbletea's cell-based renderer which cannot handle graphics protocols). type ImagePlacement struct { - Line int // Line number in the processed body text where the image starts - Base64 string // Base64-encoded image data (PNG) - Rows int // Number of terminal rows the image occupies - Uploaded bool // Whether the image has been uploaded to the terminal via Kitty ID - ID uint32 // Kitty image ID for display-by-reference + Line int // Line number in the processed body text where the image starts + Base64 string // Base64-encoded image data (PNG) + Rows int // Number of terminal rows the image occupies + Uploaded bool // Whether the image has been uploaded to the terminal via Kitty ID + ID uint32 // Kitty image ID for display-by-reference + SixelEncoded string // Cached Sixel escape sequence (encode once, reuse on scroll) } // ProcessBodyWithInline renders the body and resolves CID inline images when provided. diff --git a/view/html_test.go b/view/html_test.go index 625971b4e569f2a7be079796c109349a4377620d..8b11d89be83ca00c16ad2f89bc8efd740a1b77d8 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -173,6 +173,112 @@ func TestGhosttySupported(t *testing.T) { } } +func TestZellijDetection(t *testing.T) { + tests := []struct { + name string + env map[string]string + expected bool + }{ + {"ZELLIJ set", map[string]string{"ZELLIJ": "1"}, true}, + {"ZELLIJ_SESSION_NAME set", map[string]string{"ZELLIJ_SESSION_NAME": "test"}, true}, + {"No Zellij", map[string]string{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore env + origZellij := os.Getenv("ZELLIJ") + origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME") + defer func() { + if origZellij != "" { + os.Setenv("ZELLIJ", origZellij) + } else { + os.Unsetenv("ZELLIJ") + } + if origZellijSession != "" { + os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession) + } else { + os.Unsetenv("ZELLIJ_SESSION_NAME") + } + }() + + // Clear first + os.Unsetenv("ZELLIJ") + os.Unsetenv("ZELLIJ_SESSION_NAME") + + // Set test env + for k, v := range tt.env { + os.Setenv(k, v) + } + + if got := zellijSupported(); got != tt.expected { + t.Errorf("zellijSupported() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSixelDetection(t *testing.T) { + tests := []struct { + name string + env map[string]string + expected bool + }{ + {"Zellij", map[string]string{"ZELLIJ": "1"}, true}, + {"MLterm", map[string]string{"TERM": "mlterm"}, true}, + {"foot", map[string]string{"TERM": "foot"}, true}, + {"xterm with SIXEL", map[string]string{"TERM": "xterm", "SIXEL": "1"}, true}, + {"plain xterm", map[string]string{"TERM": "xterm"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore env + origZellij := os.Getenv("ZELLIJ") + origZellijSession := os.Getenv("ZELLIJ_SESSION_NAME") + origTerm := os.Getenv("TERM") + origSixel := os.Getenv("SIXEL") + defer func() { + if origZellij != "" { + os.Setenv("ZELLIJ", origZellij) + } else { + os.Unsetenv("ZELLIJ") + } + if origZellijSession != "" { + os.Setenv("ZELLIJ_SESSION_NAME", origZellijSession) + } else { + os.Unsetenv("ZELLIJ_SESSION_NAME") + } + if origTerm != "" { + os.Setenv("TERM", origTerm) + } else { + os.Unsetenv("TERM") + } + if origSixel != "" { + os.Setenv("SIXEL", origSixel) + } else { + os.Unsetenv("SIXEL") + } + }() + + // Clear all env first + os.Unsetenv("ZELLIJ") + os.Unsetenv("ZELLIJ_SESSION_NAME") + os.Unsetenv("TERM") + os.Unsetenv("SIXEL") + + // Set test env + for k, v := range tt.env { + os.Setenv(k, v) + } + + if got := sixelSupported(); got != tt.expected { + t.Errorf("sixelSupported() = %v, want %v", got, tt.expected) + } + }) + } +} + func TestImageProtocolSupported(t *testing.T) { // Save original environment variables origTerm := os.Getenv("TERM")