Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 27 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
```
Expand Down
54 changes: 40 additions & 14 deletions cmd/gaze/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,45 @@ func main() {
}

func newRootCmd() *cobra.Command {
var staticMode bool
var (
staticMode bool
rendererType string
)

cmd := &cobra.Command{
Use: "gaze <image>",
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)
}
Comment on lines +57 to +65

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRenderer は "tmux" を指定すると常に renderer.NewTmuxRenderer() を返しますが、tmux 外や tmux コマンド不在でもそのまま進むため(pane offset が 0,0 になりやすく)「何も表示されない/位置が不正」になっても利用者が原因を特定しづらいです。NewTmuxRenderer / createRenderer のどちらかで tmux セッション判定や依存コマンド有無チェックを行い、満たさない場合は明示的にエラーにすることを検討してください。

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c80e4db で修正 — コメント #3 と同一コミットで対応しました

}

func runStatic(args []string, rendererType string) error {
imagePath := args[0]

// Load image
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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))
}

Expand Down
7 changes: 5 additions & 2 deletions internal/adapter/renderer/kitty_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading