From 38db57adb96b06ec5c3aee36408dd77811d2a248 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:11:41 +0900 Subject: [PATCH 1/6] refactor: export QueryCellSize and add QueryTerminalSize Export queryCellSize as QueryCellSize so it can be called from outside the tui package. Add QueryTerminalSize to expose terminal column/row counts via TIOCGWINSZ for use by the new static display mode. Co-Authored-By: Claude Opus 4.6 --- internal/adapter/tui/cellsize.go | 14 ++++++++++++-- internal/adapter/tui/cellsize_windows.go | 9 +++++++-- internal/adapter/tui/model.go | 2 +- internal/adapter/tui/update.go | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/internal/adapter/tui/cellsize.go b/internal/adapter/tui/cellsize.go index 1e169f7..0d57d97 100644 --- a/internal/adapter/tui/cellsize.go +++ b/internal/adapter/tui/cellsize.go @@ -13,9 +13,9 @@ const ( defaultCellHeight = 16.0 ) -// queryCellSize returns the terminal cell pixel dimensions using TIOCGWINSZ. +// QueryCellSize returns the terminal cell pixel dimensions using TIOCGWINSZ. // Falls back to 8x16 if pixel dimensions are unavailable. -func queryCellSize() (cellWidth, cellHeight float64) { +func QueryCellSize() (cellWidth, cellHeight float64) { ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) if err != nil { return defaultCellWidth, defaultCellHeight @@ -30,3 +30,13 @@ func queryCellSize() (cellWidth, cellHeight float64) { return cellWidth, cellHeight } + +// QueryTerminalSize returns the terminal size in columns and rows using TIOCGWINSZ. +// Returns (0, 0) if the terminal size cannot be determined. +func QueryTerminalSize() (cols, rows int) { + ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) + if err != nil { + return 0, 0 + } + return int(ws.Col), int(ws.Row) +} diff --git a/internal/adapter/tui/cellsize_windows.go b/internal/adapter/tui/cellsize_windows.go index b501994..adf633d 100644 --- a/internal/adapter/tui/cellsize_windows.go +++ b/internal/adapter/tui/cellsize_windows.go @@ -5,8 +5,13 @@ const ( defaultCellHeight = 16.0 ) -// queryCellSize returns default cell pixel dimensions on Windows. +// QueryCellSize returns default cell pixel dimensions on Windows. // TIOCGWINSZ is not available on Windows. -func queryCellSize() (cellWidth, cellHeight float64) { +func QueryCellSize() (cellWidth, cellHeight float64) { return defaultCellWidth, defaultCellHeight } + +// QueryTerminalSize returns (0, 0) on Windows as TIOCGWINSZ is not available. +func QueryTerminalSize() (cols, rows int) { + return 0, 0 +} diff --git a/internal/adapter/tui/model.go b/internal/adapter/tui/model.go index 9862054..4b5b7fb 100644 --- a/internal/adapter/tui/model.go +++ b/internal/adapter/tui/model.go @@ -34,7 +34,7 @@ func NewModel( renderFrame usecase.RenderFrameUseCase, ) Model { vp := domain.NewViewport(cfg.Viewport) - cellW, cellH := queryCellSize() + cellW, cellH := QueryCellSize() vp.SetCellAspectRatio(cellH / cellW) vp.SetImageSize(img.Width, img.Height) diff --git a/internal/adapter/tui/update.go b/internal/adapter/tui/update.go index bf2e829..72427ae 100644 --- a/internal/adapter/tui/update.go +++ b/internal/adapter/tui/update.go @@ -14,7 +14,7 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - cellW, cellH := queryCellSize() + cellW, cellH := QueryCellSize() m.viewport.SetCellAspectRatio(cellH / cellW) m.viewport.SetTerminalSize(msg.Width, msg.Height-1) // -1 for status bar m.ready = true From 67fbbf5d7921a22062801539f1178c0a322cb170 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:11:49 +0900 Subject: [PATCH 2/6] feat: add --static flag for non-interactive image display Add a --static flag that displays the image to the terminal and exits immediately without entering the interactive TUI. Useful for scripts, pipelines, and quick image previews. Co-Authored-By: Claude Opus 4.6 --- cmd/gaze/main.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 109526b..14f036b 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -11,6 +11,7 @@ import ( "github.com/flexphere/gaze/internal/adapter/config" "github.com/flexphere/gaze/internal/adapter/renderer" "github.com/flexphere/gaze/internal/adapter/tui" + "github.com/flexphere/gaze/internal/domain" "github.com/flexphere/gaze/internal/infrastructure/filesystem" "github.com/flexphere/gaze/internal/usecase" ) @@ -24,20 +25,73 @@ func main() { } func newRootCmd() *cobra.Command { + var staticMode bool + 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.", Args: cobra.ExactArgs(1), - RunE: runViewer, SilenceUsage: true, } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if staticMode { + return runStatic(args) + } + return runViewer(cmd, args) + } + + cmd.Flags().BoolVar(&staticMode, "static", false, "Display image and exit without interactive mode") cmd.Version = version return cmd } +func runStatic(args []string) error { + imagePath := args[0] + + // Load image + imageLoader := filesystem.NewImageLoader() + loadImageUC := usecase.NewLoadImageUseCase(imageLoader) + img, err := loadImageUC.Execute(imagePath) + if err != nil { + return fmt.Errorf("loading image: %w", err) + } + + // Query terminal dimensions + cols, rows := tui.QueryTerminalSize() + if cols <= 0 || rows <= 0 { + return fmt.Errorf("unable to determine terminal size") + } + + // Build viewport for full image display + cellW, cellH := tui.QueryCellSize() + vp := &domain.Viewport{ + TermWidth: cols, + TermHeight: rows, + ImgWidth: img.Width, + ImgHeight: img.Height, + CellAspectRatio: cellH / cellW, + ZoomLevel: 1.0, + } + vp.FitToWindow() + + // Upload and display + kittyRenderer := renderer.NewKittyRenderer() + if err := kittyRenderer.Upload(img); err != nil { + return fmt.Errorf("uploading image: %w", err) + } + + output, err := kittyRenderer.Display(vp) + if err != nil { + return fmt.Errorf("displaying image: %w", err) + } + + fmt.Print(output) + return nil +} + func runViewer(_ *cobra.Command, args []string) error { imagePath := args[0] From b289ec363e24be1ae8682aba3e04e4909a85f6ec Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:21:34 +0900 Subject: [PATCH 3/6] feat: display image at native size in static mode Calculate display dimensions from the image's native pixel size and cell pixel size so the image is shown at 1:1 resolution. When the native width exceeds the terminal width, scale down proportionally to fit. Co-Authored-By: Claude Opus 4.6 --- cmd/gaze/main.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 14f036b..5e4c191 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "math" "os" tea "github.com/charmbracelet/bubbletea" @@ -65,11 +66,19 @@ func runStatic(args []string) error { return fmt.Errorf("unable to determine terminal size") } - // Build viewport for full image display + // Calculate native cell dimensions (1:1 pixel mapping), capped at terminal width cellW, cellH := tui.QueryCellSize() + nativeCols := int(math.Ceil(float64(img.Width) / cellW)) + nativeRows := int(math.Ceil(float64(img.Height) / cellH)) + displayCols := nativeCols + if displayCols > cols { + displayCols = cols + } + + // Build viewport sized to native display area vp := &domain.Viewport{ - TermWidth: cols, - TermHeight: rows, + TermWidth: displayCols, + TermHeight: nativeRows, ImgWidth: img.Width, ImgHeight: img.Height, CellAspectRatio: cellH / cellW, From 2cf8694602a6daada8e21e63c0f7dc7e2389fa5d Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:36:38 +0900 Subject: [PATCH 4/6] fix: guard QueryTerminalSize against zero col/row Normalize to (0, 0) when either ws.Col or ws.Row is zero, matching the existing guard in QueryCellSize. Co-Authored-By: Claude Opus 4.6 --- internal/adapter/tui/cellsize.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/adapter/tui/cellsize.go b/internal/adapter/tui/cellsize.go index 0d57d97..5db09e6 100644 --- a/internal/adapter/tui/cellsize.go +++ b/internal/adapter/tui/cellsize.go @@ -38,5 +38,8 @@ func QueryTerminalSize() (cols, rows int) { if err != nil { return 0, 0 } + if ws.Col == 0 || ws.Row == 0 { + return 0, 0 + } return int(ws.Col), int(ws.Row) } From e825ed72db9cd352485a1cbc3148c6ebd4b7ced3 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:36:46 +0900 Subject: [PATCH 5/6] fix: improve static mode viewport init and cursor handling - Use NewViewport with DefaultConfig to inherit zoom/pan limits - Improve error message to indicate TTY requirement - Strip cursor-to-home escape so image displays inline at prompt - Move cursor below image after display for clean shell prompt Co-Authored-By: Claude Opus 4.6 --- cmd/gaze/main.go | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 5e4c191..6956681 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -61,9 +62,9 @@ func runStatic(args []string) error { } // Query terminal dimensions - cols, rows := tui.QueryTerminalSize() - if cols <= 0 || rows <= 0 { - return fmt.Errorf("unable to determine terminal size") + cols, _ := tui.QueryTerminalSize() + if cols <= 0 { + return fmt.Errorf("determining terminal size: --static requires a TTY") } // Calculate native cell dimensions (1:1 pixel mapping), capped at terminal width @@ -75,16 +76,12 @@ func runStatic(args []string) error { displayCols = cols } - // Build viewport sized to native display area - vp := &domain.Viewport{ - TermWidth: displayCols, - TermHeight: nativeRows, - ImgWidth: img.Width, - ImgHeight: img.Height, - CellAspectRatio: cellH / cellW, - ZoomLevel: 1.0, - } - vp.FitToWindow() + // Build viewport via constructor to inherit default zoom/pan limits + cfg := domain.DefaultConfig() + vp := domain.NewViewport(cfg.Viewport) + vp.SetCellAspectRatio(cellH / cellW) + vp.SetTerminalSize(displayCols, nativeRows) + vp.SetImageSize(img.Width, img.Height) // Upload and display kittyRenderer := renderer.NewKittyRenderer() @@ -97,7 +94,24 @@ func runStatic(args []string) error { return fmt.Errorf("displaying image: %w", err) } + // Strip cursor-to-home (\x1b[H) used by interactive mode; static displays inline + output = strings.TrimPrefix(output, "\x1b[H") + + // Calculate display rows to position cursor below the image + cellAspect := vp.CellAspect() + imgAspect := float64(img.Width) / float64(img.Height) + termAspect := float64(displayCols) / (float64(nativeRows) * cellAspect) + dispRows := nativeRows + if imgAspect > termAspect { + dispRows = int(math.Round(float64(displayCols) / imgAspect / cellAspect)) + } + if dispRows <= 0 { + dispRows = 1 + } + + // Display image and move cursor below it fmt.Print(output) + fmt.Printf("\x1b[%dB\n", dispRows) return nil } From 0db8f3737c941bf10350344ba15892f57b8584ac Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Wed, 25 Mar 2026 14:46:25 +0900 Subject: [PATCH 6/6] fix: cap display rows at terminal height for tall images Limit nativeRows to the actual terminal height so very tall images are scaled to fit on screen instead of being clipped by the terminal. Co-Authored-By: Claude Opus 4.6 --- cmd/gaze/main.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 6956681..efac531 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -62,12 +62,12 @@ func runStatic(args []string) error { } // Query terminal dimensions - cols, _ := tui.QueryTerminalSize() - if cols <= 0 { + cols, rows := tui.QueryTerminalSize() + if cols <= 0 || rows <= 0 { return fmt.Errorf("determining terminal size: --static requires a TTY") } - // Calculate native cell dimensions (1:1 pixel mapping), capped at terminal width + // Calculate native cell dimensions (1:1 pixel mapping), capped at terminal size cellW, cellH := tui.QueryCellSize() nativeCols := int(math.Ceil(float64(img.Width) / cellW)) nativeRows := int(math.Ceil(float64(img.Height) / cellH)) @@ -75,12 +75,16 @@ func runStatic(args []string) error { if displayCols > cols { displayCols = cols } + displayRows := nativeRows + if displayRows > rows { + displayRows = rows + } // Build viewport via constructor to inherit default zoom/pan limits cfg := domain.DefaultConfig() vp := domain.NewViewport(cfg.Viewport) vp.SetCellAspectRatio(cellH / cellW) - vp.SetTerminalSize(displayCols, nativeRows) + vp.SetTerminalSize(displayCols, displayRows) vp.SetImageSize(img.Width, img.Height) // Upload and display @@ -97,11 +101,11 @@ func runStatic(args []string) error { // Strip cursor-to-home (\x1b[H) used by interactive mode; static displays inline output = strings.TrimPrefix(output, "\x1b[H") - // Calculate display rows to position cursor below the image + // Calculate actual display rows to position cursor below the image cellAspect := vp.CellAspect() imgAspect := float64(img.Width) / float64(img.Height) - termAspect := float64(displayCols) / (float64(nativeRows) * cellAspect) - dispRows := nativeRows + termAspect := float64(displayCols) / (float64(displayRows) * cellAspect) + dispRows := displayRows if imgAspect > termAspect { dispRows = int(math.Round(float64(displayCols) / imgAspect / cellAspect)) }