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
83 changes: 82 additions & 1 deletion cmd/gaze/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package main
import (
"errors"
"fmt"
"math"
"os"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

"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"
)
Expand All @@ -24,20 +27,98 @@ func main() {
}

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

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.",
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)
Comment on lines +84 to +88
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

vp.SetTerminalSize(displayCols, nativeRows) に nativeRows(画像サイズ由来)をそのまま渡すと、縦長画像で nativeRows が非常に大きくなり、kitty への r= 指定やカーソル移動量が実際のターミナル高さと大きく乖離します。QueryTerminalSize() で rows も取れているので、高さ方向も min(nativeRows, rows) のように上限を設けて実ターミナルサイズに合わせるのが安全です。

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.

0db8f37 で修正 — displayRows = min(nativeRows, rows) でターミナル高さを上限にしました


// 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))
Comment on lines +106 to +110
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

dispRows の算出ロジックが KittyRenderer.Display() 内の表示サイズ計算(aspect ratio ベース)と重複しています。将来 Display() 側の計算式や丸めが変わると、--static のカーソル移動量だけがズレてプロンプト位置が崩れる可能性があるため、表示に使う displayRows を単一の関数(例: renderer 側で「表示 cols/rows を返す」API)から取得する形に寄せるのが安全です。

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.

重複は認識しています。表示サイズを返す API を追加すると RendererPort インターフェースの変更が必要になるため、この PR のスコープ外とし将来のリファクタリング候補とします

}
if dispRows <= 0 {
dispRows = 1
}

// Display image and move cursor below it
fmt.Print(output)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

kittyRenderer.Display(vp) の出力は先頭に \x1b[H(カーソルを左上へ移動)を含むため、--static で alt-screen を使わずに即終了すると、シェルのプロンプトが左上に出て画像を上書きしやすいです。static モードでは表示前後でカーソル位置を save/restore する、表示後に最終行へカーソル移動して改行を出す、または static 用にカーソル移動を含まない表示シーケンスを生成する等の対応を検討してください。

Suggested change
fmt.Print(output)
// 画像表示後にカーソルをターミナル最終行の先頭へ移動し、改行を出力することで
// シェルのプロンプトが画像の左上を上書きしにくくする。
cursorToLastLine := fmt.Sprintf("\x1b[%d;1H\n", rows)
fmt.Print(output + cursorToLastLine)

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.

e825ed7 で修正 — \x1b[H を除去してインライン表示にし、表示後にカーソルを画像下端に移動するようにしました

fmt.Printf("\x1b[%dB\n", dispRows)
return nil
}

func runViewer(_ *cobra.Command, args []string) error {
imagePath := args[0]

Expand Down
17 changes: 15 additions & 2 deletions internal/adapter/tui/cellsize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

QueryTerminalSize のコメントは「判定できない場合は (0, 0)」となっていますが、実装は ws.Col / ws.Row の片方だけが 0 の場合に (0, N) や (N, 0) を返し得ます(QueryCellSize では 0 チェックあり)。呼び出し側が片方だけを見落とすと不正なサイズを使ってしまうので、ws.Col == 0 || ws.Row == 0 の場合は (0,0) に正規化するか、コメントを実装に合わせてください。

Suggested change
}
}
if ws.Col == 0 || ws.Row == 0 {
return 0, 0
}

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.

2cf8694 で修正 — QueryCellSize と同様の ws.Col == 0 || ws.Row == 0 ガードを追加しました

if ws.Col == 0 || ws.Row == 0 {
return 0, 0
}
return int(ws.Col), int(ws.Row)
Comment on lines +36 to +44
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

QueryTerminalSizeos.Stdout の FD を前提にしているため、stdout がリダイレクト/パイプの場合にサイズ取得が必ず失敗します(ただし --static の用途としては gaze --static img | cat のようなパイプライン表示も考えられ、その場合でも制御 TTY からサイズを取れた方が扱いやすいです)。os.Stdin.Fd()/dev/tty を開いて ioctl する、または golang.org/x/term.GetSize を使って「実際に表示される TTY」から cols/rows を取得する実装にすると意図に合います。

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.

妥当な改善点です。ただし --static で stdout をパイプする場合はエスケープシーケンス自体が無意味になるため、現時点では実用上の問題になりません。将来的に /dev/tty からの取得に変更することを検討します

}
9 changes: 7 additions & 2 deletions internal/adapter/tui/cellsize_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +14 to +16
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

QueryTerminalSize() が Windows で常に (0,0) を返す実装だと、--static は実質的に Windows では利用不能になります。未対応方針であれば呼び出し側が判別できるように error を返す設計にするか、対応するなら golang.org/x/term.GetSize 等で cols/rows を取得する実装を検討してください。

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.

gaze は Kitty Graphics Protocol 前提のため現時点で Windows は対象外です

}
2 changes: 1 addition & 1 deletion internal/adapter/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading