diff --git a/README.md b/README.md index e807465..259bc64 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ A terminal image viewer with zoom and pan support, powered by the [Kitty Graphic > Other terminals supporting the Kitty Graphics Protocol should also work. +### tmux + +gaze works inside tmux by automatically wrapping Kitty graphics sequences in DCS passthrough. Add this to your `~/.tmux.conf`: + +``` +set -g allow-passthrough on +``` + ## Installation ```bash diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 109526b..0edda32 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -57,7 +57,11 @@ func runViewer(_ *cobra.Command, args []string) error { } // Create renderer and use cases - kittyRenderer := renderer.NewKittyRenderer() + var rendererOpts []renderer.Option + if os.Getenv("TMUX") != "" { + rendererOpts = append(rendererOpts, renderer.WithTmuxMode(true)) + } + kittyRenderer := renderer.NewKittyRenderer(rendererOpts...) vpCtrl := usecase.NewViewportControlUseCase() renderFrameUC := usecase.NewRenderFrameUseCase(kittyRenderer, cfg.Minimap) diff --git a/internal/adapter/renderer/kitty_renderer.go b/internal/adapter/renderer/kitty_renderer.go index 389c924..99a0603 100644 --- a/internal/adapter/renderer/kitty_renderer.go +++ b/internal/adapter/renderer/kitty_renderer.go @@ -18,6 +18,18 @@ import ( var imageIDCounter uint32 +// Option configures KittyRenderer behavior. +type Option func(*KittyRenderer) + +// WithTmuxMode enables tmux DCS passthrough wrapping for Kitty escape sequences. +// When enabled, all Kitty APC sequences are wrapped in tmux DCS passthrough +// so the outer terminal receives them directly. +func WithTmuxMode(enabled bool) Option { + return func(r *KittyRenderer) { + r.tmuxMode = enabled + } +} + // KittyRenderer implements RendererPort using the Kitty Graphics Protocol. type KittyRenderer struct { imageID uint32 @@ -33,11 +45,43 @@ type KittyRenderer struct { prevIndicator [4]int // pxLeft, pxTop, pxRight, pxBottom prevBorderColor string // cached border color prevCached bool // true when cache is valid + + tmuxMode bool // wrap Kitty sequences in tmux DCS passthrough } // NewKittyRenderer creates a new KittyRenderer. -func NewKittyRenderer() *KittyRenderer { - return &KittyRenderer{} +func NewKittyRenderer(opts ...Option) *KittyRenderer { + r := &KittyRenderer{} + for _, opt := range opts { + opt(r) + } + return r +} + +// wrapSeq wraps a Kitty APC sequence in tmux DCS passthrough when tmux mode +// is enabled. In normal mode the sequence is returned unchanged. +func (r *KittyRenderer) wrapSeq(seq string) string { + if !r.tmuxMode { + return seq + } + return wrapDCSPassthrough(seq) +} + +// wrapDCSPassthrough wraps an escape sequence in tmux DCS passthrough. +// Each ESC (\x1b) in the payload is doubled for DCS encoding. +// Format: \x1bPtmux;\x1b\\ +func wrapDCSPassthrough(seq string) string { + var buf strings.Builder + buf.Grow(len(seq) + 32) + buf.WriteString("\x1bPtmux;") + for i := 0; i < len(seq); i++ { + if seq[i] == '\x1b' { + buf.WriteByte('\x1b') + } + buf.WriteByte(seq[i]) + } + buf.WriteString("\x1b\\") + return buf.String() } // Upload encodes and transmits the image to the terminal via Kitty graphics protocol. @@ -95,17 +139,19 @@ func (r *KittyRenderer) Display(vp *domain.Viewport) (string, error) { // Clear previous display and show new frame // Move cursor to top-left, clear screen area, then display - output := "\x1b[H" // move cursor to top-left - output += fmt.Sprintf("\x1b_Ga=p,i=%d,x=%d,y=%d,w=%d,h=%d,c=%d,r=%d,q=2\x1b\\", + apc := fmt.Sprintf("\x1b_Ga=p,i=%d,x=%d,y=%d,w=%d,h=%d,c=%d,r=%d,q=2\x1b\\", r.imageID, srcX, srcY, srcW, srcH, displayCols, displayRows) + output := "\x1b[H" + r.wrapSeq(apc) + return output, nil } // Clear removes the image from the terminal. func (r *KittyRenderer) Clear() error { if r.imageID > 0 { - fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.imageID) + apc := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.imageID) + fmt.Print(r.wrapSeq(apc)) } return nil } @@ -116,7 +162,8 @@ func (r *KittyRenderer) Clear() error { func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, cellAspect float64) error { // Delete existing minimap image from terminal before assigning a new ID if r.minimapID > 0 { - fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) + apc := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) + fmt.Print(r.wrapSeq(apc)) } r.minimapID = atomic.AddUint32(&imageIDCounter, 1) @@ -217,8 +264,9 @@ func (r *KittyRenderer) DisplayMinimap(vp *domain.Viewport, cols, rows int, bord } // Build placement command (always needed since main image re-render may overwrite) - placeCmd := fmt.Sprintf("\x1b[%d;%dH\x1b_Ga=p,i=%d,c=%d,r=%d,z=1,q=2\x1b\\", - startRow, startCol, r.minimapID, cols, rows) + placeAPC := fmt.Sprintf("\x1b_Ga=p,i=%d,c=%d,r=%d,z=1,q=2\x1b\\", + r.minimapID, cols, rows) + placeCmd := fmt.Sprintf("\x1b[%d;%dH", startRow, startCol) + r.wrapSeq(placeAPC) // Skip re-upload if indicator rectangle and border color haven't changed. // The image is already in terminal memory; just re-place it. @@ -237,7 +285,7 @@ func (r *KittyRenderer) DisplayMinimap(vp *domain.Viewport, cols, rows int, bord var out strings.Builder // 1. Upload frame with raw RGBA (f=32) — same ID auto-replaces old image - uploadSeq := buildRGBAUploadSequence(r.minimapID, r.minimapFrame) + uploadSeq := r.buildRGBAUploadSequence(r.minimapID, r.minimapFrame) out.WriteString(uploadSeq) // 2. Place minimap @@ -254,7 +302,8 @@ func (r *KittyRenderer) DisplayMinimap(vp *domain.Viewport, cols, rows int, bord // ClearMinimap removes the minimap image from the terminal. func (r *KittyRenderer) ClearMinimap() error { if r.minimapID > 0 { - fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) + apc := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) + fmt.Print(r.wrapSeq(apc)) } // Invalidate cache so next DisplayMinimap re-uploads r.prevCached = false @@ -340,7 +389,8 @@ func drawRectBorder(img *image.RGBA, left, top, right, bottom int, c color.RGBA) // buildUploadSequence creates the Kitty upload escape sequences as a string // using PNG encoding. Used for the main image upload. -func buildUploadSequence(id uint32, img image.Image) (string, error) { +// Each chunk is wrapped for tmux DCS passthrough when tmux mode is enabled. +func (r *KittyRenderer) buildUploadSequence(id uint32, img image.Image) (string, error) { var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return "", fmt.Errorf("encoding image to PNG: %w", err) @@ -362,11 +412,13 @@ func buildUploadSequence(id uint32, img image.Image) (string, error) { more = 0 } + var apc string if i == 0 { - fmt.Fprintf(&out, "\x1b_Gi=%d,f=100,a=t,t=d,q=2,m=%d;%s\x1b\\", id, more, chunk) + apc = fmt.Sprintf("\x1b_Gi=%d,f=100,a=t,t=d,q=2,m=%d;%s\x1b\\", id, more, chunk) } else { - fmt.Fprintf(&out, "\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) + apc = fmt.Sprintf("\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) } + out.WriteString(r.wrapSeq(apc)) } return out.String(), nil @@ -375,7 +427,8 @@ func buildUploadSequence(id uint32, img image.Image) (string, error) { // buildRGBAUploadSequence creates Kitty upload escape sequences using raw RGBA // pixel data (f=32). This is much faster than PNG encoding since it skips the // compression step and uses the image's pixel buffer directly. -func buildRGBAUploadSequence(id uint32, img *image.RGBA) string { +// Each chunk is wrapped for tmux DCS passthrough when tmux mode is enabled. +func (r *KittyRenderer) buildRGBAUploadSequence(id uint32, img *image.RGBA) string { bounds := img.Bounds() w := bounds.Dx() h := bounds.Dy() @@ -410,12 +463,14 @@ func buildRGBAUploadSequence(id uint32, img *image.RGBA) string { more = 0 } + var apc string if i == 0 { - fmt.Fprintf(&out, "\x1b_Gi=%d,f=32,s=%d,v=%d,a=t,t=d,q=2,m=%d;%s\x1b\\", + apc = fmt.Sprintf("\x1b_Gi=%d,f=32,s=%d,v=%d,a=t,t=d,q=2,m=%d;%s\x1b\\", id, w, h, more, chunk) } else { - fmt.Fprintf(&out, "\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) + apc = fmt.Sprintf("\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) } + out.WriteString(r.wrapSeq(apc)) } return out.String() @@ -423,7 +478,7 @@ func buildRGBAUploadSequence(id uint32, img *image.RGBA) string { // uploadImage encodes and transmits an image to the terminal. func (r *KittyRenderer) uploadImage(id uint32, img image.Image) error { - seq, err := buildUploadSequence(id, img) + seq, err := r.buildUploadSequence(id, img) if err != nil { return err } diff --git a/internal/adapter/renderer/kitty_renderer_test.go b/internal/adapter/renderer/kitty_renderer_test.go index ebb41e8..c523252 100644 --- a/internal/adapter/renderer/kitty_renderer_test.go +++ b/internal/adapter/renderer/kitty_renderer_test.go @@ -261,11 +261,12 @@ func TestKittyRenderer_DisplayMinimap_CacheHit(t *testing.T) { } func TestBuildRGBAUploadSequence(t *testing.T) { + r := NewKittyRenderer() img := image.NewRGBA(image.Rect(0, 0, 4, 4)) // Set a pixel to verify data is included img.SetRGBA(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) - output := buildRGBAUploadSequence(42, img) + output := r.buildRGBAUploadSequence(42, img) if !strings.Contains(output, "i=42") { t.Error("output should contain image ID") @@ -288,12 +289,13 @@ func TestBuildRGBAUploadSequence(t *testing.T) { } func TestBuildRGBAUploadSequence_SubImage(t *testing.T) { + r := NewKittyRenderer() // Create a larger image and take a sub-image (non-zero origin, stride != 4*w) full := image.NewRGBA(image.Rect(0, 0, 8, 8)) full.SetRGBA(2, 2, color.RGBA{R: 42, G: 0, B: 0, A: 255}) sub := full.SubImage(image.Rect(2, 2, 6, 6)).(*image.RGBA) - output := buildRGBAUploadSequence(10, sub) + output := r.buildRGBAUploadSequence(10, sub) if !strings.Contains(output, "s=4") { t.Error("sub-image should report width=4") @@ -409,6 +411,156 @@ func TestKittyRenderer_DisplayMinimap_NoBase(t *testing.T) { } } +func TestWrapDCSPassthrough(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + "simple APC", + "\x1b_Ga=d,d=i,i=1\x1b\\", + "\x1bPtmux;\x1b\x1b_Ga=d,d=i,i=1\x1b\x1b\\\x1b\\", + }, + { + "no ESC chars", + "hello", + "\x1bPtmux;hello\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapDCSPassthrough(tt.input) + if got != tt.want { + t.Errorf("wrapDCSPassthrough(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestKittyRenderer_TmuxMode_Display(t *testing.T) { + r := NewKittyRenderer(WithTmuxMode(true)) + r.imageID = 1 + r.imgW = 800 + r.imgH = 600 + + vp := domain.NewViewport(domain.ViewportConfig{ + ZoomStep: 0.1, PanStep: 0.05, MinZoom: 0.1, MaxZoom: 20.0, + }) + vp.ImgWidth = 800 + vp.ImgHeight = 600 + vp.TermWidth = 80 + vp.TermHeight = 24 + vp.CellAspectRatio = 2.0 + + output, err := r.Display(vp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should start with cursor move (NOT wrapped) + if !strings.HasPrefix(output, "\x1b[H") { + t.Error("output should start with cursor move \\x1b[H") + } + + // APC should be wrapped in DCS passthrough + if !strings.Contains(output, "\x1bPtmux;") { + t.Error("tmux mode output should contain DCS passthrough prefix") + } + // The wrapped content should still contain the placement params + if !strings.Contains(output, "a=p") { + t.Error("tmux mode output should contain action=place") + } + if !strings.Contains(output, "i=1") { + t.Error("tmux mode output should contain image ID") + } +} + +func TestKittyRenderer_TmuxMode_DisplayMinimap(t *testing.T) { + r := NewKittyRenderer(WithTmuxMode(true)) + r.minimapID = 2 + r.minimapW = 128 + r.minimapH = 96 + r.minimapBase = image.NewRGBA(image.Rect(0, 0, 128, 96)) + r.minimapFrame = image.NewRGBA(image.Rect(0, 0, 128, 96)) + + vp := domain.NewViewport(domain.ViewportConfig{ + ZoomStep: 0.1, PanStep: 0.05, MinZoom: 0.1, MaxZoom: 20.0, + }) + vp.ImgWidth = 800 + vp.ImgHeight = 600 + vp.TermWidth = 80 + vp.TermHeight = 24 + vp.ZoomLevel = 2.0 + + output, err := r.DisplayMinimap(vp, 16, 6, "#FFFFFF") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Upload chunks should be wrapped + if !strings.Contains(output, "\x1bPtmux;") { + t.Error("tmux mode minimap should contain DCS passthrough") + } + // Placement APC should also be wrapped + if count := strings.Count(output, "\x1bPtmux;"); count < 2 { + t.Errorf("expected at least 2 DCS wrappers (upload + placement), got %d", count) + } +} + +func TestKittyRenderer_TmuxMode_DisplayMinimap_CacheHit(t *testing.T) { + r := NewKittyRenderer(WithTmuxMode(true)) + r.minimapID = 2 + r.minimapW = 128 + r.minimapH = 96 + r.minimapBase = image.NewRGBA(image.Rect(0, 0, 128, 96)) + r.minimapFrame = image.NewRGBA(image.Rect(0, 0, 128, 96)) + + vp := domain.NewViewport(domain.ViewportConfig{ + ZoomStep: 0.1, PanStep: 0.05, MinZoom: 0.1, MaxZoom: 20.0, + }) + vp.ImgWidth = 800 + vp.ImgHeight = 600 + vp.TermWidth = 80 + vp.TermHeight = 24 + vp.ZoomLevel = 2.0 + + // First call — full upload + _, err := r.DisplayMinimap(vp, 16, 6, "#FFFFFF") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Second call — cache hit, placement only + output, err := r.DisplayMinimap(vp, 16, 6, "#FFFFFF") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Placement should still be wrapped in tmux mode + if !strings.Contains(output, "\x1bPtmux;") { + t.Error("cached placement should still be wrapped in tmux mode") + } +} + +func TestKittyRenderer_TmuxMode_BuildRGBAUploadSequence(t *testing.T) { + r := NewKittyRenderer(WithTmuxMode(true)) + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + + output := r.buildRGBAUploadSequence(42, img) + + // Each chunk should be individually wrapped + if !strings.Contains(output, "\x1bPtmux;") { + t.Error("tmux mode upload should wrap chunks in DCS passthrough") + } + // Should NOT contain raw (unwrapped) APC start + // In wrapped form, \x1b_ becomes \x1b\x1b_ inside the DCS payload + if strings.Contains(output, "\x1b_G") && !strings.Contains(output, "\x1b\x1b_G") { + t.Error("tmux mode should not contain unwrapped APC sequences") + } +} + func TestClampInt(t *testing.T) { tests := []struct { name string