diff --git a/src/internal/ui/preview/model.go b/src/internal/ui/preview/model.go index 2e9d0d571..29758bb05 100644 --- a/src/internal/ui/preview/model.go +++ b/src/internal/ui/preview/model.go @@ -15,18 +15,15 @@ import ( "strings" "time" - "github.com/yorukot/superfile/src/internal/ui" - "github.com/yorukot/superfile/src/internal/ui/rendering" - - "github.com/yorukot/superfile/src/internal/common" - "github.com/yorukot/superfile/src/internal/utils" - "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/lipgloss" - "github.com/yorukot/ansichroma" "github.com/yorukot/superfile/src/config/icon" + "github.com/yorukot/superfile/src/internal/common" + "github.com/yorukot/superfile/src/internal/ui" + "github.com/yorukot/superfile/src/internal/ui/rendering" + "github.com/yorukot/superfile/src/internal/utils" filepreview "github.com/yorukot/superfile/src/pkg/file_preview" ) @@ -73,7 +70,7 @@ func (m *Model) Close() { func (m *Model) RenderText(text string) string { return ui.FilePreviewPanelRenderer(m.height, m.width). AddLines(text). - Render() + m.imagePreviewer.ClearKittyImages() + Render() + m.imagePreviewer.ClearAllImages() } func (m *Model) SetContentWithRenderText(text string) { @@ -170,7 +167,7 @@ func (m *Model) renderImagePreview(box lipgloss.Style, itemPath string, previewW } // Use the new auto-detection function to choose the best renderer - imageRender, err := m.imagePreviewer.ImagePreview(itemPath, previewWidth, previewHeight, + imageRender, err, renderedType := m.imagePreviewer.ImagePreview(itemPath, previewWidth, previewHeight, common.Theme.FilePanelBG, sideAreaWidth) if errors.Is(err, image.ErrFormat) { return box.Render("\n --- " + icon.Error + " Unsupported image formats ---") @@ -183,7 +180,7 @@ func (m *Model) renderImagePreview(box lipgloss.Style, itemPath string, previewW // Check if this looks like Kitty protocol output (starts with escape sequences) // For Kitty protocol, avoid using lipgloss alignment to prevent layout drift - if strings.HasPrefix(imageRender, "\x1b_G") { + if renderedType != filepreview.RendererANSI { rendered := common.FilePreviewBox(previewHeight, previewWidth).Render(imageRender) return rendered } @@ -246,7 +243,7 @@ func (m *Model) RenderWithPath(itemPath string, fullModelWidth int) string { box := common.FilePreviewBox(previewHeight, previewWidth) r := ui.FilePreviewPanelRenderer(previewHeight, previewWidth) - clearCmd := m.imagePreviewer.ClearKittyImages() + clearCmd := m.imagePreviewer.ClearAllImages() fileInfo, infoErr := os.Stat(itemPath) if infoErr != nil { diff --git a/src/pkg/file_preview/image_preview.go b/src/pkg/file_preview/image_preview.go index a879956f5..985024c87 100644 --- a/src/pkg/file_preview/image_preview.go +++ b/src/pkg/file_preview/image_preview.go @@ -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() +} diff --git a/src/pkg/file_preview/inline.go b/src/pkg/file_preview/inline.go new file mode 100644 index 000000000..8aef41044 --- /dev/null +++ b/src/pkg/file_preview/inline.go @@ -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) +} + +// 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() +}