Conversation
Show a thumbnail overview with viewport indicator at bottom-right when the image is zoomed in. The minimap is rendered by compositing the indicator rectangle directly onto a downscaled thumbnail image each frame, with all Kitty escape sequences output atomically to prevent timing issues. Border color is configurable via TOML config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover IsZoomed, MinimapConfig defaults, minimap rendering logic (enabled/disabled, zoom state, small terminal skip, upload-once, error paths), and KittyRenderer minimap methods (DisplayMinimap, UploadMinimap, cursor positioning, edge cases, parseHexColor, clampInt). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
ズーム時(ZoomLevel > 1.0)に画面右下へミニマップ(縮小サムネイル+表示領域インジケータ)を描画できるようにし、設定(TOML)で有効化・サイズ・枠線色を調整できるようにするPRです。
Changes:
- domain に
Viewport.IsZoomed()とMinimapConfig、デフォルト設定を追加 - usecase にミニマップ描画フロー(Upload/Display)と
RendererPort拡張を追加 - KittyRenderer にミニマップ生成・インジケータ描画・アトミック出力、TOML ローダーと
cmd/gazeのDI/クリーンアップを更新
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/usecase/render_frame.go | ズーム時のミニマップ描画をRenderFrameに統合 |
| internal/usecase/render_frame_test.go | RenderFrameUseCase のミニマップ分岐のテスト追加 |
| internal/usecase/ports.go | RendererPort にミニマップ用メソッドを追加 |
| internal/domain/viewport.go | IsZoomed() を追加 |
| internal/domain/viewport_test.go | IsZoomed() の境界テスト追加 |
| internal/domain/config.go | MinimapConfig とデフォルト値を追加 |
| internal/domain/config_test.go | ミニマップデフォルト値のテスト追加 |
| internal/adapter/renderer/kitty_renderer.go | Kittyでのミニマップ生成・描画・削除を実装 |
| internal/adapter/renderer/kitty_renderer_test.go | KittyRenderer のミニマップ関連テスト追加 |
| internal/adapter/config/toml_loader.go | TOMLからミニマップ設定を読み込むよう拡張 |
| cmd/gaze/main.go | MinimapConfig をDIし、終了時にミニマップもクリーンアップ |
| // Copy base thumbnail | ||
| bounds := r.minimapBase.Bounds() | ||
| frame := image.NewRGBA(bounds) | ||
| copy(frame.Pix, r.minimapBase.Pix) | ||
|
|
There was a problem hiding this comment.
DisplayMinimap() で r.minimapBase を毎フレーム image.NewRGBA で新規確保してコピーしており、フレームレートが高い環境ではGC負荷が増えます。ミニマップ用の作業バッファ(*image.RGBA)を KittyRenderer に保持して再利用する、または Pix スライスを再利用して copy するなど、割り当て回数を減らす改善を検討してください。
| pxTop = clampInt(pxTop, 0, r.minimapH-1) | ||
| pxRight = clampInt(pxRight, 1, r.minimapW) | ||
| pxBottom = clampInt(pxBottom, 1, r.minimapH) | ||
|
|
There was a problem hiding this comment.
VisibleRect() の丸め次第で pxRight==pxLeft / pxBottom==pxTop になり得ます(高ズーム時など)。その場合 drawRectBorder の描画が不安定になり、インジケータが消える/崩れる可能性があります。クランプ後に right<=left のときは right=left+1、bottom<=top のときは bottom=top+1 など、最小1px以上の矩形になるよう補正してください(上限も bounds 内に再クランプ)。
| // Ensure the rectangle has at least 1px width/height after clamping. | |
| if pxRight <= pxLeft { | |
| pxRight = pxLeft + 1 | |
| } | |
| if pxBottom <= pxTop { | |
| pxBottom = pxTop + 1 | |
| } | |
| // Re-clamp to bounds to keep the indicator fully inside the minimap image. | |
| if pxRight > bounds.Max.X { | |
| pxRight = bounds.Max.X | |
| } | |
| if pxBottom > bounds.Max.Y { | |
| pxBottom = bounds.Max.Y | |
| } |
| var buf bytes.Buffer | ||
| if err := png.Encode(&buf, img); err != nil { | ||
| return fmt.Errorf("encoding image to PNG: %w", err) | ||
| } | ||
|
|
||
| encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) | ||
|
|
||
| const chunkSize = 4096 | ||
| for i := 0; i < len(encoded); i += chunkSize { | ||
| end := i + chunkSize | ||
| if end > len(encoded) { | ||
| end = len(encoded) | ||
| } | ||
| chunk := encoded[i:end] | ||
|
|
||
| more := 1 | ||
| if end >= len(encoded) { | ||
| more = 0 | ||
| } | ||
|
|
||
| if i == 0 { | ||
| fmt.Printf("\x1b_Gi=%d,f=100,a=t,t=d,m=%d;%s\x1b\\", id, more, chunk) | ||
| } else { | ||
| fmt.Printf("\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
buildUploadSequence() と uploadImage() で PNGエンコード→base64→4096byte分割の処理が重複しています。片方をもう片方に寄せて共通化(例: buildUploadSequence を使って uploadImage は fmt.Print するだけにする)すると、将来のプロトコルパラメータ変更時の修正漏れを防げます。
| var buf bytes.Buffer | |
| if err := png.Encode(&buf, img); err != nil { | |
| return fmt.Errorf("encoding image to PNG: %w", err) | |
| } | |
| encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) | |
| const chunkSize = 4096 | |
| for i := 0; i < len(encoded); i += chunkSize { | |
| end := i + chunkSize | |
| if end > len(encoded) { | |
| end = len(encoded) | |
| } | |
| chunk := encoded[i:end] | |
| more := 1 | |
| if end >= len(encoded) { | |
| more = 0 | |
| } | |
| if i == 0 { | |
| fmt.Printf("\x1b_Gi=%d,f=100,a=t,t=d,m=%d;%s\x1b\\", id, more, chunk) | |
| } else { | |
| fmt.Printf("\x1b_Gi=%d,m=%d;%s\x1b\\", id, more, chunk) | |
| } | |
| } | |
| seq, err := buildUploadSequence(id, img) | |
| if err != nil { | |
| return err | |
| } | |
| fmt.Print(seq) |
| if err := kittyRenderer.ClearMinimap(); err != nil { | ||
| return fmt.Errorf("clearing minimap: %w", err) | ||
| } | ||
| if err := kittyRenderer.Clear(); err != nil { | ||
| return fmt.Errorf("clearing renderer: %w", err) |
There was a problem hiding this comment.
クリーンアップ処理で ClearMinimap() が失敗すると、その後の Clear() が呼ばれず端末上にメイン画像が残る可能性があります。両方のクリーンアップは可能な限り実行し、エラーはまとめて返す(例: 先に Clear() も試してから複合エラーにする)形にすると後片付けが確実になります。
| // Append minimap when zoomed in and enabled | ||
| if uc.minimapCfg.Enabled && vp.IsZoomed() { | ||
| minimapCols, minimapRows := uc.minimapSize(vp) | ||
| if minimapCols >= 5 && minimapRows >= 3 { | ||
| if !uc.minimapUploaded { | ||
| if err := uc.renderer.UploadMinimap(img, minimapCols, minimapRows); err != nil { | ||
| return "", fmt.Errorf("uploading minimap: %w", err) | ||
| } | ||
| uc.minimapUploaded = true | ||
| } | ||
|
|
||
| mmOutput, err := uc.renderer.DisplayMinimap(vp, minimapCols, minimapRows, uc.minimapCfg.BorderColor) | ||
| if err != nil { | ||
| return "", fmt.Errorf("displaying minimap: %w", err) | ||
| } | ||
| output += mmOutput | ||
| } | ||
| } |
There was a problem hiding this comment.
ミニマップを一度表示した後にズーム解除(ZoomLevel<=1.001)や端末が小さい等で条件を満たさなくなっても、ここでは ClearMinimap() が呼ばれないため、前フレームのミニマップが端末上に残り続ける可能性があります。前回ミニマップを表示していた場合は、非表示条件になったタイミングで renderer.ClearMinimap() を呼ぶ(必要なら表示状態を uc 側で保持する)ようにして、ズームアウト時に確実に消えるようにしてください。
| if minimapCols >= 5 && minimapRows >= 3 { | ||
| if !uc.minimapUploaded { |
There was a problem hiding this comment.
minimapCols>=5 / minimapRows>=3 の閾値がマジックナンバーになっており、仕様変更時に追従漏れが起きやすいです。意味(例: 最小表示可能サイズ)を表す定数にして名前付けするか、MinimapConfig 側で最小値として設定可能にすることを検討してください。
| cfg.Minimap.Enabled = *tc.Minimap.Enabled | ||
| } | ||
| if tc.Minimap.Size != nil { | ||
| cfg.Minimap.Size = *tc.Minimap.Size |
There was a problem hiding this comment.
minimapCfg.Size がTOMLからそのまま取り込まれており、範囲外(負値や極端に大きい値)でも受け入れてしまいます。その結果、描画側で cols/rows が過大になってメモリアロケーションが膨らむ・表示が崩れるなどのリスクがあります。ここで Size を 0<Size<=1 にクランプする、もしくは不正値の場合はデフォルトにフォールバック/エラーにする等のバリデーションを追加してください。
| cfg.Minimap.Size = *tc.Minimap.Size | |
| size := *tc.Minimap.Size | |
| if size > 0 && size <= 1 { | |
| cfg.Minimap.Size = size | |
| } |
| // UploadMinimap creates a downscaled thumbnail base image for the minimap. | ||
| // The base is kept in memory; actual upload happens in DisplayMinimap each frame | ||
| // (with the viewport indicator rectangle drawn on top). | ||
| func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int) error { |
There was a problem hiding this comment.
UploadMinimap() を複数回呼んだ場合に、既存の minimapID を削除せず新しいIDへ上書きするため、端末側に古いミニマップ画像が残ってリークする可能性があります(ClearMinimap() は最新IDしか消しません)。新しいIDを採番する前に既存IDを削除する、または既存IDを再利用するなどして後始末を入れてください。
| func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int) error { | |
| func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int) error { | |
| // 既存の minimap 画像があれば、端末側から削除してから新しい ID を採番する | |
| if r.minimapID > 0 { | |
| fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) | |
| } |
概要
resolves #1
#FFFFFF)変更内容
IsZoomed()メソッド、MinimapConfig構造体を追加RendererPortにミニマップ用メソッド3つ追加、RenderFrameUseCaseでミニマップ描画ロジックを実装KittyRendererにミニマップ描画実装(CatmullRom 補間でダウンスケール、矩形インジケータをイメージ上に直接描画、全エスケープシーケンスをアトミックに出力)enabled,size,border_color)を追加設定例
テスト計画
IsZoomed()の境界値テスト(6ケース)MinimapConfigデフォルト値テストKittyRendererミニマップメソッドテスト(10件: DisplayMinimap、UploadMinimap、カーソル位置、エッジケース、parseHexColor、clampInt)make ci全チェックパス🤖 Generated with Claude Code