diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 109526b..efac531 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -3,7 +3,9 @@ package main import ( "errors" "fmt" + "math" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -11,6 +13,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 +27,98 @@ 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("determining terminal size: --static requires a TTY") + } + + // 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)) + displayCols := nativeCols + 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, displayRows) + vp.SetImageSize(img.Width, img.Height) + + // 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) + } + + // Strip cursor-to-home (\x1b[H) used by interactive mode; static displays inline + output = strings.TrimPrefix(output, "\x1b[H") + + // Calculate actual display rows to position cursor below the image + cellAspect := vp.CellAspect() + imgAspect := float64(img.Width) / float64(img.Height) + termAspect := float64(displayCols) / (float64(displayRows) * cellAspect) + dispRows := displayRows + 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 +} + func runViewer(_ *cobra.Command, args []string) error { imagePath := args[0] diff --git a/internal/adapter/tui/cellsize.go b/internal/adapter/tui/cellsize.go index 1e169f7..5db09e6 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,16 @@ 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 + } + if ws.Col == 0 || ws.Row == 0 { + 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