diff --git a/README.md b/README.md index e807465..da1cb74 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ A terminal image viewer with zoom and pan support, powered by the [Kitty Graphic ## Supported Terminals -| Terminal | Status | -|----------|--------| -| [Kitty](https://sw.kovidgoyal.net/kitty/) | Supported | -| [Ghostty](https://ghostty.org/) | Supported | -| [WezTerm](https://wezfurlong.org/wezterm/) | Supported | +| Terminal | Support | +| ------------------------------------------ | ------------------------ | +| [Kitty](https://sw.kovidgoyal.net/kitty/) | Supported | +| [Ghostty](https://ghostty.org/) | Supported | +| [WezTerm](https://wezfurlong.org/wezterm/) | Supported | +| tmux (with `allow-passthrough on`) | Experimental (`-r tmux`) | > Other terminals supporting the Kitty Graphics Protocol should also work. @@ -47,31 +48,35 @@ gaze photo.png gaze screenshot.jpg gaze animation.gif gaze image.webp + +# Use inside tmux (requires: tmux set -g allow-passthrough on) +gaze -r tmux photo.png +gaze --renderer tmux photo.png ``` ## Controls ### Keyboard -| Key | Action | -|-----|--------| -| `h` / `Left` | Pan left | -| `j` / `Down` | Pan down | -| `k` / `Up` | Pan up | -| `l` / `Right` | Pan right | -| `+` / `=` | Zoom in | -| `-` / `_` | Zoom out | -| `f` | Fit to window | -| `0` / `r` | Reset view | -| `m` | Toggle minimap | -| `q` / `Esc` / `Ctrl+C` | Quit | +| Key | Action | +| ---------------------- | -------------- | +| `h` / `Left` | Pan left | +| `j` / `Down` | Pan down | +| `k` / `Up` | Pan up | +| `l` / `Right` | Pan right | +| `+` / `=` | Zoom in | +| `-` / `_` | Zoom out | +| `f` | Fit to window | +| `0` / `r` | Reset view | +| `m` | Toggle minimap | +| `q` / `Esc` / `Ctrl+C` | Quit | ### Mouse -| Action | Effect | -|--------|--------| -| Drag | Pan the image | -| Scroll up | Zoom in (at cursor position) | +| Action | Effect | +| ----------- | ----------------------------- | +| Drag | Pan the image | +| Scroll up | Zoom in (at cursor position) | | Scroll down | Zoom out (at cursor position) | ## Configuration @@ -120,7 +125,7 @@ internal/ adapter/ tui/ Bubbletea TUI (Model/Update/View, KeyMap, StatusBar) config/ TOML configuration loader - renderer/ Kitty Graphics Protocol implementation + renderer/ Kitty Graphics / tmux passthrough protocol implementations infrastructure/ filesystem/ Image file loading (PNG, JPEG, GIF, BMP, TIFF, WebP) ``` diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index efac531..3953597 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -27,30 +27,45 @@ func main() { } func newRootCmd() *cobra.Command { - var staticMode bool + var ( + staticMode bool + rendererType string + ) cmd := &cobra.Command{ Use: "gaze ", Short: "Terminal image viewer with zoom and pan", - Long: "gaze is a terminal image viewer that supports zoom, pan, and mouse interaction using Kitty Graphics Protocol.", + Long: "gaze is a terminal image viewer that supports zoom, pan, and mouse interaction using the Kitty Graphics Protocol.", Args: cobra.ExactArgs(1), SilenceUsage: true, } cmd.RunE = func(cmd *cobra.Command, args []string) error { if staticMode { - return runStatic(args) + return runStatic(args, rendererType) } - return runViewer(cmd, args) + return runViewer(cmd, args, rendererType) } cmd.Flags().BoolVar(&staticMode, "static", false, "Display image and exit without interactive mode") + cmd.Flags().StringVarP(&rendererType, "renderer", "r", "kitty", "Renderer protocol (kitty, tmux)") cmd.Version = version return cmd } -func runStatic(args []string) error { +func createRenderer(rendererType string) (usecase.RendererPort, error) { + switch rendererType { + case "kitty": + return renderer.NewKittyRenderer(), nil + case "tmux": + return renderer.NewTmuxRenderer() + default: + return nil, fmt.Errorf("unknown renderer type %q: supported values are kitty, tmux", rendererType) + } +} + +func runStatic(args []string, rendererType string) error { imagePath := args[0] // Load image @@ -88,12 +103,15 @@ func runStatic(args []string) error { vp.SetImageSize(img.Width, img.Height) // Upload and display - kittyRenderer := renderer.NewKittyRenderer() - if err := kittyRenderer.Upload(img); err != nil { + imgRenderer, err := createRenderer(rendererType) + if err != nil { + return err + } + if err := imgRenderer.Upload(img); err != nil { return fmt.Errorf("uploading image: %w", err) } - output, err := kittyRenderer.Display(vp) + output, err := imgRenderer.Display(vp) if err != nil { return fmt.Errorf("displaying image: %w", err) } @@ -119,7 +137,7 @@ func runStatic(args []string) error { return nil } -func runViewer(_ *cobra.Command, args []string) error { +func runViewer(_ *cobra.Command, args []string, rendererType string) error { imagePath := args[0] // Load configuration @@ -138,13 +156,21 @@ func runViewer(_ *cobra.Command, args []string) error { } // Create renderer and use cases - kittyRenderer := renderer.NewKittyRenderer() + imgRenderer, err := createRenderer(rendererType) + if err != nil { + return err + } vpCtrl := usecase.NewViewportControlUseCase() - renderFrameUC := usecase.NewRenderFrameUseCase(kittyRenderer, cfg.Minimap) + renderFrameUC := usecase.NewRenderFrameUseCase(imgRenderer, cfg.Minimap) // Create TUI model model := tui.NewModel(img, cfg, vpCtrl, renderFrameUC) + // Wire up resize callback for renderers that need it (e.g. tmux pane offset) + if tmuxR, ok := imgRenderer.(*renderer.TmuxRenderer); ok { + model.SetOnResize(tmuxR.RefreshPaneOffset) + } + // Run Bubbletea program p := tea.NewProgram( model, @@ -156,12 +182,12 @@ func runViewer(_ *cobra.Command, args []string) error { return fmt.Errorf("running viewer: %w", err) } - // Clean up Kitty graphics — always attempt both cleanups + // Clean up renderer — always attempt both cleanups var errs []error - if err := kittyRenderer.ClearMinimap(); err != nil { + if err := imgRenderer.ClearMinimap(); err != nil { errs = append(errs, fmt.Errorf("clearing minimap: %w", err)) } - if err := kittyRenderer.Clear(); err != nil { + if err := imgRenderer.Clear(); err != nil { errs = append(errs, fmt.Errorf("clearing renderer: %w", err)) } diff --git a/internal/adapter/renderer/kitty_renderer.go b/internal/adapter/renderer/kitty_renderer.go index 54529c0..29f0286 100644 --- a/internal/adapter/renderer/kitty_renderer.go +++ b/internal/adapter/renderer/kitty_renderer.go @@ -119,7 +119,12 @@ func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, c fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) } r.minimapID = atomic.AddUint32(&imageIDCounter, 1) + r.prepareMinimapBase(img, cols, rows, cellAspect) + return nil +} +// prepareMinimapBase downscales the image and prepares reusable buffers. +func (r *KittyRenderer) prepareMinimapBase(img *domain.ImageEntity, cols, rows int, cellAspect float64) { // Calculate pixel dimensions for the minimap. // Use a base cell width and derive height from aspect ratio. const baseCellW = 8.0 @@ -158,8 +163,6 @@ func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, c r.prevIndicator = [4]int{} r.prevBorderColor = "" r.prevCached = false - - return nil } // DisplayMinimap composites the viewport indicator onto the minimap base, diff --git a/internal/adapter/renderer/tmux_renderer.go b/internal/adapter/renderer/tmux_renderer.go new file mode 100644 index 0000000..7053c1c --- /dev/null +++ b/internal/adapter/renderer/tmux_renderer.go @@ -0,0 +1,220 @@ +package renderer + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync/atomic" + + "github.com/flexphere/gaze/internal/domain" +) + +// TmuxRenderer wraps KittyRenderer, adding DCS passthrough so that +// Kitty graphics escape sequences reach the outer terminal through tmux. +// Cursor coordinates are offset by the pane position so that images +// render inside the correct pane. +type TmuxRenderer struct { + inner *KittyRenderer + paneTop int + paneLeft int +} + +// NewTmuxRenderer creates a TmuxRenderer wrapping a KittyRenderer. +// Returns an error if not running inside a tmux session. +func NewTmuxRenderer() (*TmuxRenderer, error) { + if os.Getenv("TMUX") == "" { + return nil, fmt.Errorf("tmux renderer requires a tmux session (TMUX environment variable not set)") + } + top, left, err := queryTmuxPaneOffset() + if err != nil { + return nil, fmt.Errorf("querying tmux pane offset: %w", err) + } + return &TmuxRenderer{inner: NewKittyRenderer(), paneTop: top, paneLeft: left}, nil +} + +// RefreshPaneOffset re-queries the tmux pane position. +// On failure, the previous offset is retained to avoid disrupting rendering. +func (r *TmuxRenderer) RefreshPaneOffset() { + top, left, err := queryTmuxPaneOffset() + if err != nil { + return // keep previous values + } + r.paneTop = top + r.paneLeft = left +} + +// queryTmuxPaneOffset returns the current pane's top-left corner offset +// within the outer terminal by querying tmux. +func queryTmuxPaneOffset() (top, left int, err error) { + out, err := exec.Command("tmux", "display-message", "-p", "#{pane_top} #{pane_left}").Output() + if err != nil { + return 0, 0, fmt.Errorf("running tmux display-message: %w", err) + } + parts := strings.Fields(strings.TrimSpace(string(out))) + if len(parts) < 2 { + return 0, 0, fmt.Errorf("unexpected tmux output: %q", string(out)) + } + top, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("parsing pane_top %q: %w", parts[0], err) + } + left, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("parsing pane_left %q: %w", parts[1], err) + } + return top, left, nil +} + +// Upload encodes and transmits the image with tmux DCS passthrough wrapping. +func (r *TmuxRenderer) Upload(img *domain.ImageEntity) error { + r.inner.imageID = atomic.AddUint32(&imageIDCounter, 1) + r.inner.imgW = img.Width + r.inner.imgH = img.Height + + seq, err := buildUploadSequence(r.inner.imageID, img.Source) + if err != nil { + return err + } + fmt.Print(r.wrapAllKittySequences(seq)) + return nil +} + +// Display returns the Kitty placement sequence wrapped for tmux. +func (r *TmuxRenderer) Display(vp *domain.Viewport) (string, error) { + seq, err := r.inner.Display(vp) + if err != nil { + return "", err + } + return r.wrapAllKittySequences(seq), nil +} + +// Clear removes the image from the terminal via tmux passthrough. +func (r *TmuxRenderer) Clear() error { + if r.inner.imageID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.imageID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + return nil +} + +// UploadMinimap creates a downscaled thumbnail, deleting the old one via tmux passthrough. +func (r *TmuxRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, cellAspect float64) error { + if r.inner.minimapID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.minimapID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + r.inner.minimapID = atomic.AddUint32(&imageIDCounter, 1) + r.inner.prepareMinimapBase(img, cols, rows, cellAspect) + return nil +} + +// DisplayMinimap returns the minimap sequence wrapped for tmux. +func (r *TmuxRenderer) DisplayMinimap(vp *domain.Viewport, cols, rows int, borderColor string) (string, error) { + seq, err := r.inner.DisplayMinimap(vp, cols, rows, borderColor) + if err != nil { + return "", err + } + return r.wrapAllKittySequences(seq), nil +} + +// ClearMinimap removes the minimap from the terminal via tmux passthrough. +func (r *TmuxRenderer) ClearMinimap() error { + if r.inner.minimapID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.minimapID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + r.inner.prevCached = false + return nil +} + +// wrapAllKittySequences finds all Kitty APC sequences (\x1b_G...\x1b\\) in +// the input string and wraps each one in DCS passthrough. A CSI cursor +// positioning sequence (\x1b[...H) immediately preceding a Kitty APC is +// included inside the same passthrough — with row/col adjusted by the +// pane offset — so that the image renders inside the correct tmux pane. +// +// Uses bulk copies from the input string to avoid re-copying the output +// buffer when extracting cursor moves. +func (r *TmuxRenderer) wrapAllKittySequences(s string) string { + var out strings.Builder + out.Grow(len(s)) + + written := 0 // index in s up to which we've flushed to out + + for i := 0; i < len(s); { + // Look for Kitty APC start: \x1b_G + if i+2 < len(s) && s[i] == 0x1b && s[i+1] == '_' && s[i+2] == 'G' { + // Find the ST terminator: \x1b\\ + end := strings.Index(s[i+3:], "\x1b\\") + if end >= 0 { + seqEnd := i + 3 + end + 2 // include \x1b\\ + + // Scan backwards in the pending input for a cursor move + prefix, flushEnd := r.findCursorMoveInPending(s[written:i]) + + // Flush pending bytes up to (but not including) the cursor move + out.WriteString(s[written : written+flushEnd]) + + // Wrap cursor move + Kitty sequence with cursor save/restore + // so that the outer terminal cursor is restored after drawing. + kittySeq := s[i:seqEnd] + content := "\x1b7" + prefix + kittySeq + "\x1b8" // DECSC ... DECRC + escaped := strings.ReplaceAll(content, "\x1b", "\x1b\x1b") + out.WriteString("\x1bPtmux;") + out.WriteString(escaped) + out.WriteString("\x1b\\") + + written = seqEnd + i = seqEnd + continue + } + } + i++ + } + + // Flush remaining + out.WriteString(s[written:]) + + return out.String() +} + +// findCursorMoveInPending scans the pending (not yet flushed) portion of the +// input for a trailing CSI cursor position sequence (\x1b[;H). +// If found, returns the adjusted cursor move string and the flush boundary +// (number of bytes to flush before the cursor move). If not found, returns +// ("", len(pending)) so the caller flushes everything. +func (r *TmuxRenderer) findCursorMoveInPending(pending string) (cursorMove string, flushEnd int) { + if len(pending) < 2 || pending[len(pending)-1] != 'H' { + return "", len(pending) + } + + // Walk backwards from the 'H' to find ESC [ + j := len(pending) - 2 + for j >= 0 && ((pending[j] >= '0' && pending[j] <= '9') || pending[j] == ';') { + j-- + } + if j < 1 || pending[j] != '[' || pending[j-1] != 0x1b { + return "", len(pending) + } + + csiStart := j - 1 + params := pending[j+1 : len(pending)-1] // between '[' and 'H' + + // Parse row;col (default 1;1 for bare \x1b[H) + row, col := 1, 1 + if params != "" { + parts := strings.SplitN(params, ";", 2) + if v, err := strconv.Atoi(parts[0]); err == nil { + row = v + } + if len(parts) == 2 { + if v, err := strconv.Atoi(parts[1]); err == nil { + col = v + } + } + } + + return fmt.Sprintf("\x1b[%d;%dH", row+r.paneTop, col+r.paneLeft), csiStart +} diff --git a/internal/adapter/renderer/tmux_renderer_test.go b/internal/adapter/renderer/tmux_renderer_test.go new file mode 100644 index 0000000..a69808d --- /dev/null +++ b/internal/adapter/renderer/tmux_renderer_test.go @@ -0,0 +1,213 @@ +package renderer + +import ( + "fmt" + "strings" + "testing" +) + +// wrapExpected builds the expected DCS passthrough for a Kitty sequence, +// including DECSC/DECRC cursor save/restore. +func wrapExpected(content string) string { + withSaveRestore := "\x1b7" + content + "\x1b8" + escaped := strings.ReplaceAll(withSaveRestore, "\x1b", "\x1b\x1b") + return "\x1bPtmux;" + escaped + "\x1b\\" +} + +func TestWrapAllKittySequences_SingleSequence(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := "\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + want := wrapExpected(input) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_MultipleChunks(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + chunk1 := "\x1b_Gi=1,f=100,m=1;AAAA\x1b\\" + chunk2 := "\x1b_Gi=1,m=0;BBBB\x1b\\" + input := chunk1 + chunk2 + + got := r.wrapAllKittySequences(input) + want := wrapExpected(chunk1) + wrapExpected(chunk2) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_PreserveNonKittySequences(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := "\x1b[2J" // clear screen — no Kitty sequence + got := r.wrapAllKittySequences(input) + + if got != input { + t.Errorf("non-Kitty sequence modified: got %q, want %q", got, input) + } +} + +func TestWrapAllKittySequences_CursorMoveBeforeKitty(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + // Cursor move immediately before Kitty sequence should be pulled into passthrough + input := "\x1b[H\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + + // \x1b[H with no offset (paneTop=0, paneLeft=0) becomes \x1b[1;1H + cursorMove := "\x1b[1;1H" + kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" + want := wrapExpected(cursorMove + kittySeq) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_CursorMoveWithPaneOffset(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: 5, paneLeft: 40} + + input := "\x1b[10;20H\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + + // Row 10+5=15, Col 20+40=60 + cursorMove := "\x1b[15;60H" + kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" + want := wrapExpected(cursorMove + kittySeq) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_EmptyString(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + got := r.wrapAllKittySequences("") + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestWrapAllKittySequences_PlainText(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := "hello world" + got := r.wrapAllKittySequences(input) + if got != input { + t.Errorf("got %q, want %q", got, input) + } +} + +func TestFindCursorMoveInPending(t *testing.T) { + tests := []struct { + name string + paneTop int + paneLeft int + pending string + wantMove string + wantFlush int + }{ + { + name: "bare cursor home", + pending: "\x1b[H", + wantMove: "\x1b[1;1H", + wantFlush: 0, + }, + { + name: "row and col", + pending: "\x1b[10;20H", + wantMove: "\x1b[10;20H", + wantFlush: 0, + }, + { + name: "with pane offset", + paneTop: 3, + paneLeft: 50, + pending: "\x1b[10;20H", + wantMove: "\x1b[13;70H", + wantFlush: 0, + }, + { + name: "prefix text preserved", + pending: "some text\x1b[5;10H", + wantMove: "\x1b[5;10H", + wantFlush: 9, // len("some text") + }, + { + name: "no cursor move", + pending: "just text", + wantMove: "", + wantFlush: 9, // len("just text") + }, + { + name: "empty pending", + pending: "", + wantMove: "", + wantFlush: 0, + }, + { + name: "row only", + pending: "\x1b[5H", + wantMove: "\x1b[5;1H", + wantFlush: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: tt.paneTop, paneLeft: tt.paneLeft} + + gotMove, gotFlush := r.findCursorMoveInPending(tt.pending) + + if gotMove != tt.wantMove { + t.Errorf("move: got %q, want %q", gotMove, tt.wantMove) + } + if gotFlush != tt.wantFlush { + t.Errorf("flushEnd: got %d, want %d", gotFlush, tt.wantFlush) + } + }) + } +} + +func TestWrapAllKittySequences_MixedUploadAndPlacement(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: 2, paneLeft: 10} + + // Simulate: upload chunks + cursor move + placement (like a real frame) + upload := "\x1b_Gi=1,f=100,a=t,t=d,q=2,m=0;DATA\x1b\\" + cursorAndPlace := "\x1b[5;3H\x1b_Ga=p,i=1,p=1,c=80,r=24,q=2\x1b\\" + input := upload + cursorAndPlace + + got := r.wrapAllKittySequences(input) + + // Upload wrapped without cursor move + wrapUpload := wrapExpected(upload) + + // Placement wrapped with offset cursor move: row 5+2=7, col 3+10=13 + cursorMove := "\x1b[7;13H" + placeSeq := "\x1b_Ga=p,i=1,p=1,c=80,r=24,q=2\x1b\\" + wrapPlace := wrapExpected(cursorMove + placeSeq) + + want := wrapUpload + wrapPlace + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_DeleteCommand(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", 42) + got := r.wrapAllKittySequences(input) + want := wrapExpected(input) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/internal/adapter/tui/model.go b/internal/adapter/tui/model.go index 4b5b7fb..b75cfff 100644 --- a/internal/adapter/tui/model.go +++ b/internal/adapter/tui/model.go @@ -24,6 +24,9 @@ type Model struct { dragStartTermY int dragStartOffX float64 dragStartOffY float64 + + // Optional callback invoked on window resize (e.g. to refresh tmux pane offset) + onResize func() } // NewModel creates a new TUI model. @@ -47,3 +50,8 @@ func NewModel( renderFrame: renderFrame, } } + +// SetOnResize sets a callback that is invoked on window resize events. +func (m *Model) SetOnResize(fn func()) { + m.onResize = fn +} diff --git a/internal/adapter/tui/update.go b/internal/adapter/tui/update.go index 72427ae..8b8a332 100644 --- a/internal/adapter/tui/update.go +++ b/internal/adapter/tui/update.go @@ -14,6 +14,9 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + if m.onResize != nil { + m.onResize() + } cellW, cellH := QueryCellSize() m.viewport.SetCellAspectRatio(cellH / cellW) m.viewport.SetTerminalSize(msg.Width, msg.Height-1) // -1 for status bar