fix: add cap to image rendering (#1621)

Drew Smirnoff created

## What?

Adds a cap to image rendering, so that the images dont break the whole
TUI

## Why?

Previously, big images would make the whole screen black, when scrolled.

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

view/html.go                 | 84 +++++++++++++++++++++++++++++++++++++
view/html_image_size_test.go | 67 ++++++++++++++++++++++++++++++
2 files changed, 151 insertions(+)

Detailed changes

view/html.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/termimage"
 	lru "github.com/hashicorp/golang-lru/v2"
+	"golang.org/x/term"
 )
 
 var htmlSanitizer htmlsanitizer.Sanitizer = htmlsanitizer.NewLibSanitizer()
@@ -363,9 +364,17 @@ const imageRowPlaceholderSuffix = "]]"
 func prerenderImage(payload string) (string, int) {
 	src := "data:image/png;base64," + payload
 	var buf bytes.Buffer
+
+	// Ask termimage to cap the rendered image so it can never exceed the
+	// terminal viewport. This prevents oversized/tall images from covering the
+	// whole screen or overlapping other content on scroll. We always pass a
+	// maximum in cells and let termimage convert to pixels for the active
+	// protocol.
 	_, rows, err := termimage.DisplayWithSize(&buf, src, termimage.Options{
 		Protocol:  termimage.Auto,
 		Sandboxed: true,
+		MaxWidth:  maxImageCellWidth(),
+		MaxHeight: maxImageCellHeight(),
 	})
 	if err != nil {
 		debugImageProtocol("termimage.DisplayWithSize error: %v", err)
@@ -378,6 +387,81 @@ func prerenderImage(payload string) (string, int) {
 	return buf.String(), rows
 }
 
+// maxImageCellHeight returns the maximum number of terminal rows an inline
+// image is allowed to occupy. It is always capped to a fraction of the viewport
+// so images cannot monopolize the screen during scrolling.
+func maxImageCellHeight() int {
+	const defaultRows = 25
+	_, rows, ok := getTerminalSize()
+	if !ok || rows < 1 {
+		return defaultRows
+	}
+	limit := rows * 8 / 10
+	if limit < 1 {
+		return 1
+	}
+	if limit > defaultRows {
+		return limit
+	}
+	return defaultRows
+}
+
+// maxImageCellWidth returns the maximum number of terminal columns an inline
+// image is allowed to occupy.
+func maxImageCellWidth() int {
+	const defaultCols = 80
+	cols, _, ok := getTerminalSize()
+	if !ok || cols < 1 {
+		return defaultCols
+	}
+	if cols > 4 {
+		cols -= 4
+	}
+	if cols < 1 {
+		cols = 1
+	}
+	if cols > defaultCols {
+		return cols
+	}
+	return defaultCols
+}
+
+// terminalSize caches the most recent terminal dimensions to avoid repeated
+// syscalls. It is refreshed on demand if the dimensions are unknown.
+var terminalSize struct {
+	cols, rows int
+	ok         bool
+}
+
+// getTerminalSize returns the current terminal size in columns and rows.
+func getTerminalSize() (cols, rows int, ok bool) {
+	if terminalSize.ok {
+		return terminalSize.cols, terminalSize.rows, true
+	}
+	size, ok := terminalSizeFrom(os.Stdin)
+	if !ok {
+		size, ok = terminalSizeFrom(os.Stdout)
+	}
+	if !ok || size.cols < 1 || size.rows < 1 {
+		return 0, 0, false
+	}
+	terminalSize.cols, terminalSize.rows, terminalSize.ok = size.cols, size.rows, true
+	return size.cols, size.rows, true
+}
+
+type termSize struct {
+	cols, rows int
+}
+
+// terminalSizeFrom attempts to read the terminal dimensions using the tty ioctl.
+func terminalSizeFrom(f *os.File) (termSize, bool) {
+	cols, rows, err := term.GetSize(int(f.Fd()))
+	if err != nil || cols < 1 || rows < 1 {
+		return termSize{}, false
+	}
+	return termSize{cols: cols, rows: rows}, true
+}
+
 // RenderImageToStdout writes an image directly to stdout at the given screen
 // row using cursor positioning. This bypasses bubbletea's cell-based renderer
 // which cannot handle graphics protocol escape sequences.

view/html_image_size_test.go 🔗

@@ -0,0 +1,67 @@
+package view
+
+import (
+	"os"
+	"runtime"
+	"testing"
+)
+
+func TestMaxImageCellHeight(t *testing.T) {
+	old := terminalSize
+	defer func() { terminalSize = old }()
+
+	terminalSize = struct {
+		cols, rows int
+		ok         bool
+	}{cols: 100, rows: 40, ok: true}
+
+	if got := maxImageCellHeight(); got != 32 {
+		t.Fatalf("expected maxImageCellHeight=32 for 40-row terminal, got %d", got)
+	}
+}
+
+func TestMaxImageCellWidth(t *testing.T) {
+	old := terminalSize
+	defer func() { terminalSize = old }()
+
+	terminalSize = struct {
+		cols, rows int
+		ok         bool
+	}{cols: 100, rows: 40, ok: true}
+
+	if got := maxImageCellWidth(); got != 96 {
+		t.Fatalf("expected maxImageCellWidth=96 for 100-col terminal, got %d", got)
+	}
+}
+
+func TestMaxImageCellDefaultsWhenNoTerminal(t *testing.T) {
+	old := terminalSize
+	defer func() { terminalSize = old }()
+
+	terminalSize = struct {
+		cols, rows int
+		ok         bool
+	}{}
+
+	if got := maxImageCellHeight(); got != 25 {
+		t.Fatalf("expected default height 25, got %d", got)
+	}
+	if got := maxImageCellWidth(); got != 80 {
+		t.Fatalf("expected default width 80, got %d", got)
+	}
+}
+
+func TestTerminalSizeFrom(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("TIOCGWINSZ is unavailable on Windows")
+	}
+	// The current process stdin/stdout may or may not be a terminal. We just
+	// verify the helper doesn't panic and returns sensible values when given a
+	// valid file.
+	ts, ok := terminalSizeFrom(os.Stdout)
+	if ok {
+		if ts.cols < 1 || ts.rows < 1 {
+			t.Fatalf("unexpected terminal size: %+v", ts)
+		}
+	}
+}