From 8235eaa8398bf9ea090ad01562a3bb5c0b6106d6 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Wed, 24 Jun 2026 16:50:08 +0400 Subject: [PATCH] fix: add cap to image rendering (#1621) ## 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 --- view/html.go | 84 ++++++++++++++++++++++++++++++++++++ view/html_image_size_test.go | 67 ++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 view/html_image_size_test.go 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) + } + } +}