-
-
Notifications
You must be signed in to change notification settings - Fork 417
feat: make image preview support inline image protocol #1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import ( | |
| "log/slog" | ||
| "os" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
|
|
@@ -23,6 +24,7 @@ type ImageRenderer int | |
| const ( | ||
| RendererANSI ImageRenderer = iota | ||
| RendererKitty | ||
| RendererInline | ||
| ) | ||
|
|
||
| // ImagePreviewCache stores cached image previews | ||
|
|
@@ -213,10 +215,10 @@ func ConvertImageToANSI(img image.Image, defaultBGColor color.Color) string { | |
|
|
||
| // ImagePreview generates a preview of an image file | ||
| func (p *ImagePreviewer) ImagePreview(path string, maxWidth int, maxHeight int, | ||
| defaultBGColor string, sideAreaWidth int) (string, error) { | ||
| defaultBGColor string, sideAreaWidth int) (string, error, ImageRenderer) { | ||
| // Validate dimensions | ||
| if maxWidth <= 0 || maxHeight <= 0 { | ||
| return "", fmt.Errorf("dimensions must be positive (maxWidth=%d, maxHeight=%d)", maxWidth, maxHeight) | ||
| return "", fmt.Errorf("dimensions must be positive (maxWidth=%d, maxHeight=%d)", maxWidth, maxHeight), RendererANSI | ||
| } | ||
|
|
||
| // Create dimensions string for cache key | ||
|
|
@@ -226,7 +228,7 @@ func (p *ImagePreviewer) ImagePreview(path string, maxWidth int, maxHeight int, | |
| if p.IsKittyCapable() { | ||
| // Check cache for Kitty renderer | ||
| if preview, found := p.cache.Get(path, dimensions, RendererKitty); found { | ||
| return preview, nil | ||
| return preview, nil, RendererKitty | ||
| } | ||
|
|
||
| preview, err := p.ImagePreviewWithRenderer( | ||
|
|
@@ -240,16 +242,41 @@ func (p *ImagePreviewer) ImagePreview(path string, maxWidth int, maxHeight int, | |
| if err == nil { | ||
| // Cache the successful result | ||
| p.cache.Set(path, dimensions, preview, RendererKitty) | ||
| return preview, nil | ||
| return preview, nil, RendererKitty | ||
| } | ||
|
|
||
| // Fall through to next renderer if Kitty fails | ||
| slog.Error("Kitty renderer failed, trying other renderers", "error", err) | ||
| } | ||
|
|
||
| // Try inline renderer (iTerm2, WezTerm, etc.) | ||
| if p.IsInlineCapable() { | ||
| // Check cache for Inline renderer | ||
| if preview, found := p.cache.Get(path, dimensions, RendererInline); found { | ||
| return preview, nil, RendererInline | ||
| } | ||
|
|
||
| preview, err := p.ImagePreviewWithRenderer( | ||
| path, | ||
| maxWidth, | ||
| maxHeight, | ||
| defaultBGColor, | ||
| RendererInline, | ||
| sideAreaWidth, | ||
| ) | ||
| if err == nil { | ||
| // Cache the successful result | ||
| p.cache.Set(path, dimensions, preview, RendererInline) | ||
| return preview, nil, RendererInline | ||
| } | ||
|
|
||
| // Fall through to ANSI if Kitty fails | ||
| slog.Error("Kitty renderer failed, falling back to ANSI", "error", err) | ||
| // Fall through to ANSI if Inline fails | ||
| slog.Error("Inline renderer failed, falling back to ANSI", "error", err) | ||
| } | ||
|
|
||
| // Check cache for ANSI renderer | ||
| if preview, found := p.cache.Get(path, dimensions, RendererANSI); found { | ||
| return preview, nil | ||
| return preview, nil, RendererANSI | ||
| } | ||
|
|
||
| // Fall back to ANSI | ||
|
|
@@ -258,7 +285,7 @@ func (p *ImagePreviewer) ImagePreview(path string, maxWidth int, maxHeight int, | |
| // Cache the successful result | ||
| p.cache.Set(path, dimensions, preview, RendererANSI) | ||
| } | ||
| return preview, err | ||
| return preview, err, RendererANSI | ||
| } | ||
|
|
||
| // ImagePreviewWithRenderer generates an image preview using the specified renderer | ||
|
|
@@ -300,6 +327,16 @@ func (p *ImagePreviewer) ImagePreviewWithRenderer(path string, maxWidth int, max | |
| } | ||
| return result, nil | ||
|
|
||
| case RendererInline: | ||
| result, err := p.renderWithInlineUsingTermCap(img, path, originalWidth, | ||
| originalHeight, maxWidth, maxHeight, sideAreaWidth) | ||
| if err != nil { | ||
| // If inline fails, fall back to ANSI renderer | ||
| slog.Error("Inline renderer failed, falling back to ANSI", "error", err) | ||
| return p.ANSIRenderer(img, defaultBGColor, maxWidth, maxHeight) | ||
| } | ||
| return result, nil | ||
|
|
||
| case RendererANSI: | ||
| return p.ANSIRenderer(img, defaultBGColor, maxWidth, maxHeight) | ||
| default: | ||
|
|
@@ -335,3 +372,25 @@ func colorToHex(color color.Color) string { | |
| r, g, b, _ := color.RGBA() | ||
| return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) | ||
| } | ||
|
|
||
| // ClearAllImages clears all images from the terminal using the appropriate protocol | ||
| // This method intelligently detects terminal capabilities and clears images accordingly | ||
| func (p *ImagePreviewer) ClearAllImages() string { | ||
| var result strings.Builder | ||
|
|
||
| // Clear Kitty protocol images if supported | ||
| if p.IsKittyCapable() { | ||
| if clearCmd := p.ClearKittyImages(); clearCmd != "" { | ||
| result.WriteString(clearCmd) | ||
| } | ||
| } | ||
|
|
||
| // Clear inline protocol images if supported | ||
| if p.IsInlineCapable() { | ||
| if clearCmd := p.ClearInlineImage(); clearCmd != "" { | ||
| result.WriteString(clearCmd) | ||
| } | ||
| } | ||
|
|
||
| return result.String() | ||
| } | ||
|
Comment on lines
+378
to
+396
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ New issue: Bumpy Road Ahead |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| package filepreview | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "image" | ||
| "log/slog" | ||
| "os" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/BourgeoisBear/rasterm" | ||
| ) | ||
|
|
||
| // isInlineCapable checks if the terminal supports inline image protocol (iTerm2, WezTerm, etc.) | ||
| func isInlineCapable() bool { | ||
| isCapable := rasterm.IsItermCapable() | ||
|
|
||
| // Additional detection for terminals that might not be detected by rasterm | ||
| if !isCapable { | ||
| termProgram := os.Getenv("TERM_PROGRAM") | ||
| term := os.Getenv("TERM") | ||
|
|
||
| // List of known terminal identifiers that support inline image protocol | ||
| knownTerminals := []string{ | ||
| "iTerm2", | ||
| "iTerm.app", | ||
| "WezTerm", | ||
| "Hyper", | ||
| "Terminus", | ||
| "Tabby", | ||
| } | ||
|
|
||
| for _, knownTerm := range knownTerminals { | ||
| if strings.EqualFold(termProgram, knownTerm) || strings.EqualFold(term, knownTerm) { | ||
| isCapable = true | ||
| break | ||
| } | ||
| } | ||
|
|
||
| // Additional check for iTerm2 specific environment variables | ||
| if !isCapable && (os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "") { | ||
| isCapable = true | ||
| } | ||
| } | ||
|
|
||
| return isCapable | ||
| } | ||
|
|
||
|
|
||
| // ClearInlineImage clears all inline image protocol images from the terminal | ||
| func (p *ImagePreviewer) ClearInlineImage() string { | ||
| if !p.IsInlineCapable() { | ||
| return "" // No need to clear if terminal doesn't support inline protocol | ||
| } | ||
|
|
||
| return strings.Repeat(" ", 9999) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's precompute this. Store it in a predefined variable |
||
| } | ||
|
|
||
| // renderWithInlineUsingTermCap renders an image using inline image protocol | ||
| func (p *ImagePreviewer) renderWithInlineUsingTermCap(img image.Image, path string, | ||
| originalWidth, originalHeight, maxWidth, maxHeight int, sideAreaWidth int) (string, error) { | ||
|
|
||
| // Validate dimensions | ||
| if maxWidth <= 0 || maxHeight <= 0 { | ||
| return "", fmt.Errorf("dimensions must be positive (maxWidth=%d, maxHeight=%d)", maxWidth, maxHeight) | ||
| } | ||
|
|
||
| var buf bytes.Buffer | ||
|
|
||
| slog.Debug("inline renderer starting", "path", path, "maxWidth", maxWidth, "maxHeight", maxHeight) | ||
|
|
||
| // Calculate display dimensions in character cells | ||
| imgRatio := float64(originalWidth) / float64(originalHeight) | ||
| termRatio := float64(maxWidth) / float64(maxHeight) | ||
|
|
||
| var displayWidthCells, displayHeightCells int | ||
|
|
||
| if imgRatio > termRatio { | ||
| // Image is wider, constrain by width | ||
| displayWidthCells = maxWidth | ||
| displayHeightCells = int(float64(maxWidth) / imgRatio) | ||
| } else { | ||
| // Image is taller, constrain by height | ||
| displayHeightCells = maxHeight | ||
| displayWidthCells = int(float64(maxHeight) * imgRatio) | ||
| } | ||
|
|
||
| // Ensure minimum dimensions | ||
| if displayWidthCells < 1 { | ||
| displayWidthCells = 1 | ||
| } | ||
| if displayHeightCells < 1 { | ||
| displayHeightCells = 1 | ||
| } | ||
|
|
||
| slog.Debug("inline display dimensions", "widthCells", displayWidthCells, "heightCells", displayHeightCells) | ||
|
|
||
| // Use rasterm to write the image using iTerm2/WezTerm protocol | ||
| if err := rasterm.ItermWriteImage(&buf, img); err != nil { | ||
| return "", fmt.Errorf("failed to write image using rasterm: %w", err) | ||
| } | ||
|
|
||
| buf.WriteString("\x1b[1;" + strconv.Itoa(sideAreaWidth) + "H") | ||
|
|
||
| return buf.String(), nil | ||
| } | ||
|
|
||
| // IsInlineCapable checks if the terminal supports inline image protocol | ||
| func (p *ImagePreviewer) IsInlineCapable() bool { | ||
| return isInlineCapable() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❌ New issue: Bumpy Road Ahead
ImagePreview has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is one single, nested block per function
Suppress