diff --git a/view/html.go b/view/html.go
index cd4e68dc5015bb3c38fc5a93f17f8ca8af1cb6d5..de9635a9c8a460d6ef8af9362d7c829f8ffb677d 100644
--- a/view/html.go
+++ b/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.
diff --git a/view/html_image_size_test.go b/view/html_image_size_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..b41636690f8c80c2f7927288f10833034f37832d
--- /dev/null
+++ b/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)
+ }
+ }
+}