Skip to content
Draft
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
19 changes: 8 additions & 11 deletions src/internal/ui/preview/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ---")
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 67 additions & 8 deletions src/pkg/file_preview/image_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"log/slog"
"os"
"strconv"
"strings"
"sync"
"time"

Expand All @@ -23,6 +24,7 @@ type ImageRenderer int
const (
RendererANSI ImageRenderer = iota
RendererKitty
RendererInline
)

// ImagePreviewCache stores cached image previews
Expand Down Expand Up @@ -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) {

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

// 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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

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
ClearAllImages has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is one single, nested block per function

Suppress

112 changes: 112 additions & 0 deletions src/pkg/file_preview/inline.go
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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's precompute this. Store it in a predefined variable InlineImageCleanupString or something. Will save a bit of processing, and will make code more readable.

}

// 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()
}
Loading