fix(tui): stabilize inline image scrolling
- commit
- a81472cc65837107ce8cd8f154c5bff041dea54e
- author
- patriceckhart · · 2026-04-26 09:20 UTC
- parent
- 487b097
- signature
- unsigned
- refs
- tag: v0.1.17
5 files · +177 -42
A internal/agent/modes/image_scroll_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSnapViewportStartToImageBlock(t *testing.T) { | |
| 6 | + | chat := []string{ | |
| 7 | + | "before", | |
| 8 | + | " \x1b_Ga=T,f=100,c=60;AAAA\x1b\\", | |
| 9 | + | "", | |
| 10 | + | "", | |
| 11 | + | " image - image/png - 100x100 - 1 KB", | |
| 12 | + | "after", | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if got := snapViewportStartToImageBlock(chat, 2); got != 1 { | |
| 16 | + | t.Fatalf("start in first reserved row snapped to %d, want 1", got) | |
| 17 | + | } | |
| 18 | + | if got := snapViewportStartToImageBlock(chat, 3); got != 1 { | |
| 19 | + | t.Fatalf("start in middle reserved row snapped to %d, want 1", got) | |
| 20 | + | } | |
| 21 | + | if got := snapViewportStartToImageBlock(chat, 1); got != 1 { | |
| 22 | + | t.Fatalf("start on image row snapped to %d, want 1", got) | |
| 23 | + | } | |
| 24 | + | if got := snapViewportStartToImageBlock(chat, 4); got != 4 { | |
| 25 | + | t.Fatalf("start on metadata row snapped to %d, want 4", got) | |
| 26 | + | } | |
| 27 | + | if got := snapViewportStartToImageBlock(chat, 5); got != 5 { | |
| 28 | + | t.Fatalf("start after image snapped to %d, want 5", got) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestSnapViewportStartToImageBlockNoopsOnPlainBlank(t *testing.T) { | |
| 33 | + | chat := []string{"before", "", "after"} | |
| 34 | + | if got := snapViewportStartToImageBlock(chat, 1); got != 1 { | |
| 35 | + | t.Fatalf("plain blank snapped to %d, want 1", got) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSnapViewportStartToImageBlock(t *testing.T) { | |||
| 6 | + | chat := []string{ | |||
| 7 | + | "before", | |||
| 8 | + | " \x1b_Ga=T,f=100,c=60;AAAA\x1b\\", | |||
| 9 | + | "", | |||
| 10 | + | "", | |||
| 11 | + | " image - image/png - 100x100 - 1 KB", | |||
| 12 | + | "after", | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if got := snapViewportStartToImageBlock(chat, 2); got != 1 { | |||
| 16 | + | t.Fatalf("start in first reserved row snapped to %d, want 1", got) | |||
| 17 | + | } | |||
| 18 | + | if got := snapViewportStartToImageBlock(chat, 3); got != 1 { | |||
| 19 | + | t.Fatalf("start in middle reserved row snapped to %d, want 1", got) | |||
| 20 | + | } | |||
| 21 | + | if got := snapViewportStartToImageBlock(chat, 1); got != 1 { | |||
| 22 | + | t.Fatalf("start on image row snapped to %d, want 1", got) | |||
| 23 | + | } | |||
| 24 | + | if got := snapViewportStartToImageBlock(chat, 4); got != 4 { | |||
| 25 | + | t.Fatalf("start on metadata row snapped to %d, want 4", got) | |||
| 26 | + | } | |||
| 27 | + | if got := snapViewportStartToImageBlock(chat, 5); got != 5 { | |||
| 28 | + | t.Fatalf("start after image snapped to %d, want 5", got) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestSnapViewportStartToImageBlockNoopsOnPlainBlank(t *testing.T) { | |||
| 33 | + | chat := []string{"before", "", "after"} | |||
| 34 | + | if got := snapViewportStartToImageBlock(chat, 1); got != 1 { | |||
| 35 | + | t.Fatalf("plain blank snapped to %d, want 1", got) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/agent/modes/interactive.go +24 -19
| @@ -834,7 +834,15 @@ func (i *Interactive) redraw() { | |||
| 834 | 834 | if start < 0 { | |
| 835 | 835 | start = 0 | |
| 836 | 836 | } | |
| 837 | - | start = alignSliceStartToImageBlock(chat, start, end) | |
| 837 | + | start = snapViewportStartToImageBlock(chat, start) | |
| 838 | + | end = start + chatRows | |
| 839 | + | if end > len(chat) { | |
| 840 | + | end = len(chat) | |
| 841 | + | start = end - chatRows | |
| 842 | + | if start < 0 { | |
| 843 | + | start = 0 | |
| 844 | + | } | |
| 845 | + | } | |
| 838 | 846 | visibleChat = chat[start:end] | |
| 839 | 847 | } | |
| 840 | 848 | visibleChat = clipBottomClippedImages(visibleChat) | |
| @@ -895,29 +903,26 @@ func (i *Interactive) redraw() { | |||
| 895 | 903 | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 896 | 904 | } | |
| 897 | 905 | ||
| 898 | - | func alignSliceStartToImageBlock(chat []string, start, end int) int { | |
| 899 | - | if start <= 0 || start >= len(chat) || start >= end { | |
| 900 | - | return start | |
| 901 | - | } | |
| 902 | - | // If the slice already starts on an image row, keep it. | |
| 903 | - | if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") { | |
| 906 | + | func hasImageEscape(line string) bool { | |
| 907 | + | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | // snapViewportStartToImageBlock treats inline images as atomic blocks for | |
| 911 | + | // scrolling. Terminal image protocols draw from a single escape row into a | |
| 912 | + | // separate graphics layer; the following blank rows are only zot's reserved | |
| 913 | + | // footprint. If the viewport starts on one of those blank rows, there is no | |
| 914 | + | // correct partial-image state to render. Snap back to the escape row instead | |
| 915 | + | // so the image is either shown from its beginning or skipped entirely. | |
| 916 | + | func snapViewportStartToImageBlock(chat []string, start int) int { | |
| 917 | + | if start <= 0 || start >= len(chat) { | |
| 904 | 918 | return start | |
| 905 | 919 | } | |
| 906 | - | // When the viewport begins inside an image block, the image escape row | |
| 907 | - | // sits just above a run of blank reservation rows, followed by an | |
| 908 | - | // "image - ..." info line. In that case, snap the slice start up to the | |
| 909 | - | // escape row so the image actually renders instead of showing only the | |
| 910 | - | // reserved blank area and metadata. | |
| 911 | - | j := start | |
| 912 | - | for j < end && strings.TrimSpace(chat[j]) == "" { | |
| 913 | - | j++ | |
| 914 | - | } | |
| 915 | - | if j >= end || !strings.Contains(chat[j], "image - ") { | |
| 920 | + | if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" { | |
| 916 | 921 | return start | |
| 917 | 922 | } | |
| 918 | 923 | for k := start - 1; k >= 0; k-- { | |
| 919 | 924 | line := chat[k] | |
| 920 | - | if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") { | |
| 925 | + | if hasImageEscape(line) { | |
| 921 | 926 | return k | |
| 922 | 927 | } | |
| 923 | 928 | if strings.TrimSpace(line) != "" { | |
| @@ -960,7 +965,7 @@ func clipBottomClippedImages(lines []string) []string { | |||
| 960 | 965 | } | |
| 961 | 966 | out := append([]string(nil), lines...) | |
| 962 | 967 | for i, line := range out { | |
| 963 | - | if !strings.Contains(line, "\x1b]1337;File=") && !strings.Contains(line, "\x1b_G") { | |
| 968 | + | if !hasImageEscape(line) { | |
| 964 | 969 | continue | |
| 965 | 970 | } | |
| 966 | 971 | // Image blocks render as: image escape, zero or more blank | |
| @@ -834,7 +834,15 @@ func (i *Interactive) redraw() { | |||||
| 834 | if start < 0 { | 834 | if start < 0 { | ||
| 835 | start = 0 | 835 | start = 0 | ||
| 836 | } | 836 | } | ||
| 837 | - | start = alignSliceStartToImageBlock(chat, start, end) | 837 | + | start = snapViewportStartToImageBlock(chat, start) |
| 838 | + | end = start + chatRows | |||
| 839 | + | if end > len(chat) { | |||
| 840 | + | end = len(chat) | |||
| 841 | + | start = end - chatRows | |||
| 842 | + | if start < 0 { | |||
| 843 | + | start = 0 | |||
| 844 | + | } | |||
| 845 | + | } | |||
| 838 | visibleChat = chat[start:end] | 846 | visibleChat = chat[start:end] | ||
| 839 | } | 847 | } | ||
| 840 | visibleChat = clipBottomClippedImages(visibleChat) | 848 | visibleChat = clipBottomClippedImages(visibleChat) | ||
| @@ -895,29 +903,26 @@ func (i *Interactive) redraw() { | |||||
| 895 | i.rend.Draw(frame, cursorRow, cursorCol) | 903 | i.rend.Draw(frame, cursorRow, cursorCol) | ||
| 896 | } | 904 | } | ||
| 897 | 905 | ||||
| 898 | - | func alignSliceStartToImageBlock(chat []string, start, end int) int { | 906 | + | func hasImageEscape(line string) bool { |
| 899 | - | if start <= 0 || start >= len(chat) || start >= end { | 907 | + | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") |
| 900 | - | return start | 908 | + | } |
| 901 | - | } | 909 | + | |
| 902 | - | // If the slice already starts on an image row, keep it. | 910 | + | // snapViewportStartToImageBlock treats inline images as atomic blocks for |
| 903 | - | if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") { | 911 | + | // scrolling. Terminal image protocols draw from a single escape row into a |
| 912 | + | // separate graphics layer; the following blank rows are only zot's reserved | |||
| 913 | + | // footprint. If the viewport starts on one of those blank rows, there is no | |||
| 914 | + | // correct partial-image state to render. Snap back to the escape row instead | |||
| 915 | + | // so the image is either shown from its beginning or skipped entirely. | |||
| 916 | + | func snapViewportStartToImageBlock(chat []string, start int) int { | |||
| 917 | + | if start <= 0 || start >= len(chat) { | |||
| 904 | return start | 918 | return start | ||
| 905 | } | 919 | } | ||
| 906 | - | // When the viewport begins inside an image block, the image escape row | 920 | + | if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" { |
| 907 | - | // sits just above a run of blank reservation rows, followed by an | |||
| 908 | - | // "image - ..." info line. In that case, snap the slice start up to the | |||
| 909 | - | // escape row so the image actually renders instead of showing only the | |||
| 910 | - | // reserved blank area and metadata. | |||
| 911 | - | j := start | |||
| 912 | - | for j < end && strings.TrimSpace(chat[j]) == "" { | |||
| 913 | - | j++ | |||
| 914 | - | } | |||
| 915 | - | if j >= end || !strings.Contains(chat[j], "image - ") { | |||
| 916 | return start | 921 | return start | ||
| 917 | } | 922 | } | ||
| 918 | for k := start - 1; k >= 0; k-- { | 923 | for k := start - 1; k >= 0; k-- { | ||
| 919 | line := chat[k] | 924 | line := chat[k] | ||
| 920 | - | if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") { | 925 | + | if hasImageEscape(line) { |
| 921 | return k | 926 | return k | ||
| 922 | } | 927 | } | ||
| 923 | if strings.TrimSpace(line) != "" { | 928 | if strings.TrimSpace(line) != "" { | ||
| @@ -960,7 +965,7 @@ func clipBottomClippedImages(lines []string) []string { | |||||
| 960 | } | 965 | } | ||
| 961 | out := append([]string(nil), lines...) | 966 | out := append([]string(nil), lines...) | ||
| 962 | for i, line := range out { | 967 | for i, line := range out { | ||
| 963 | - | if !strings.Contains(line, "\x1b]1337;File=") && !strings.Contains(line, "\x1b_G") { | 968 | + | if !hasImageEscape(line) { |
| 964 | continue | 969 | continue | ||
| 965 | } | 970 | } | ||
| 966 | // Image blocks render as: image escape, zero or more blank | 971 | // Image blocks render as: image escape, zero or more blank | ||
M internal/tui/image.go +44 -13
| @@ -8,7 +8,9 @@ import ( | |||
| 8 | 8 | _ "image/gif" | |
| 9 | 9 | _ "image/jpeg" | |
| 10 | 10 | _ "image/png" | |
| 11 | + | "math" | |
| 11 | 12 | "os" | |
| 13 | + | "strconv" | |
| 12 | 14 | "strings" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| @@ -29,13 +31,14 @@ const ( | |||
| 29 | 31 | // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var | |
| 30 | 32 | // overrides the default: | |
| 31 | 33 | // | |
| 32 | - | // ZOT_INLINE_IMAGES=off -> force text fallback | |
| 33 | - | // ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol | |
| 34 | - | // ZOT_INLINE_IMAGES=kitty -> force Kitty protocol | |
| 35 | - | // ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default) | |
| 34 | + | // ZOT_INLINE_IMAGES=off -> force text fallback | |
| 35 | + | // ZOT_INLINE_IMAGES=placeholder -> force text fallback (alias for off) | |
| 36 | + | // ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol | |
| 37 | + | // ZOT_INLINE_IMAGES=kitty -> force Kitty protocol | |
| 38 | + | // ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default) | |
| 36 | 39 | func DetectImageProtocol() ImageProtocol { | |
| 37 | 40 | switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) { | |
| 38 | - | case "off", "none", "false", "0": | |
| 41 | + | case "off", "none", "false", "0", "placeholder", "text": | |
| 39 | 42 | return ImageProtocolNone | |
| 40 | 43 | case "iterm", "iterm2": | |
| 41 | 44 | return ImageProtocolITerm2 | |
| @@ -52,6 +55,14 @@ func detectImageProtocolAuto() ImageProtocol { | |||
| 52 | 55 | term := os.Getenv("TERM") | |
| 53 | 56 | kittyWindow := os.Getenv("KITTY_WINDOW_ID") | |
| 54 | 57 | ||
| 58 | + | // VS Code's integrated terminal exposes several protocol-ish env | |
| 59 | + | // combinations depending on the underlying shell/pty, but its image | |
| 60 | + | // layer is inconsistent enough that auto-enable is more annoying than | |
| 61 | + | // useful. Users can still force a protocol via ZOT_INLINE_IMAGES. | |
| 62 | + | if strings.EqualFold(termProgram, "vscode") { | |
| 63 | + | return ImageProtocolNone | |
| 64 | + | } | |
| 65 | + | ||
| 55 | 66 | if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") { | |
| 56 | 67 | return ImageProtocolKitty | |
| 57 | 68 | } | |
| @@ -128,8 +139,10 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 128 | 139 | hdr := "a=T,f=100" | |
| 129 | 140 | if maxCellsWide > 0 && maxCellsHigh > 0 { | |
| 130 | 141 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | |
| 131 | - | // rows that the native width would produce at maxCellsWide | |
| 132 | - | nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio) | |
| 142 | + | // rows that the native width would produce at maxCellsWide. | |
| 143 | + | // Round up so we don't under-reserve and let following text | |
| 144 | + | // paint over the image on terminals whose cell metrics differ. | |
| 145 | + | nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio())) | |
| 133 | 146 | if nativeRows > maxCellsHigh { | |
| 134 | 147 | hdr += fmt.Sprintf(",r=%d", maxCellsHigh) | |
| 135 | 148 | } else { | |
| @@ -172,11 +185,27 @@ func ImageDimensions(data []byte) (int, int) { | |||
| 172 | 185 | return cfg.Width, cfg.Height | |
| 173 | 186 | } | |
| 174 | 187 | ||
| 175 | - | // CellAspectRatio approximates how many pixel-rows one terminal row | |
| 176 | - | // occupies. Typical monospace cells are ~2x tall as wide; we use 2.0 | |
| 177 | - | // as a safe default. Used to compute the rendered row count when | |
| 178 | - | // scaling an image to fit a cell width. | |
| 179 | - | const CellAspectRatio = 2.0 | |
| 188 | + | // defaultCellAspectRatio approximates how many pixel-rows one terminal | |
| 189 | + | // row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0 | |
| 190 | + | // as a safe default. Used to compute the rendered row count when scaling | |
| 191 | + | // an image to fit a cell width. | |
| 192 | + | const defaultCellAspectRatio = 2.0 | |
| 193 | + | ||
| 194 | + | // CellAspectRatio returns the pixel-height / pixel-width ratio for one | |
| 195 | + | // terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row | |
| 196 | + | // reservation for terminals/fonts where the default causes overlap or | |
| 197 | + | // excessive blank space. Values outside a sane range are ignored. | |
| 198 | + | func CellAspectRatio() float64 { | |
| 199 | + | v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT")) | |
| 200 | + | if v == "" { | |
| 201 | + | return defaultCellAspectRatio | |
| 202 | + | } | |
| 203 | + | f, err := strconv.ParseFloat(v, 64) | |
| 204 | + | if err != nil || f < 0.5 || f > 5 { | |
| 205 | + | return defaultCellAspectRatio | |
| 206 | + | } | |
| 207 | + | return f | |
| 208 | + | } | |
| 180 | 209 | ||
| 181 | 210 | // RowsForInlineImage returns the number of terminal rows an image | |
| 182 | 211 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | |
| @@ -189,8 +218,10 @@ func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | |||
| 189 | 218 | // pixels per cell (horizontal) | |
| 190 | 219 | // scaleX = imageWidthPx / cellsWide | |
| 191 | 220 | // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio) | |
| 221 | + | // Round up: under-reserving by even one row is much worse than one | |
| 222 | + | // extra blank line, because following text can overwrite the image. | |
| 192 | 223 | scaleX := float64(w) / float64(cellsWide) | |
| 193 | - | rows := int(float64(h) / (scaleX * CellAspectRatio)) | |
| 224 | + | rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio()))) | |
| 194 | 225 | if rows < 1 { | |
| 195 | 226 | rows = 1 | |
| 196 | 227 | } | |
| @@ -8,7 +8,9 @@ import ( | |||||
| 8 | _ "image/gif" | 8 | _ "image/gif" | ||
| 9 | _ "image/jpeg" | 9 | _ "image/jpeg" | ||
| 10 | _ "image/png" | 10 | _ "image/png" | ||
| 11 | + | "math" | |||
| 11 | "os" | 12 | "os" | ||
| 13 | + | "strconv" | |||
| 12 | "strings" | 14 | "strings" | ||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| @@ -29,13 +31,14 @@ const ( | |||||
| 29 | // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var | 31 | // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var | ||
| 30 | // overrides the default: | 32 | // overrides the default: | ||
| 31 | // | 33 | // | ||
| 32 | - | // ZOT_INLINE_IMAGES=off -> force text fallback | 34 | + | // ZOT_INLINE_IMAGES=off -> force text fallback |
| 33 | - | // ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol | 35 | + | // ZOT_INLINE_IMAGES=placeholder -> force text fallback (alias for off) |
| 34 | - | // ZOT_INLINE_IMAGES=kitty -> force Kitty protocol | 36 | + | // ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol |
| 35 | - | // ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default) | 37 | + | // ZOT_INLINE_IMAGES=kitty -> force Kitty protocol |
| 38 | + | // ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default) | |||
| 36 | func DetectImageProtocol() ImageProtocol { | 39 | func DetectImageProtocol() ImageProtocol { | ||
| 37 | switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) { | 40 | switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) { | ||
| 38 | - | case "off", "none", "false", "0": | 41 | + | case "off", "none", "false", "0", "placeholder", "text": |
| 39 | return ImageProtocolNone | 42 | return ImageProtocolNone | ||
| 40 | case "iterm", "iterm2": | 43 | case "iterm", "iterm2": | ||
| 41 | return ImageProtocolITerm2 | 44 | return ImageProtocolITerm2 | ||
| @@ -52,6 +55,14 @@ func detectImageProtocolAuto() ImageProtocol { | |||||
| 52 | term := os.Getenv("TERM") | 55 | term := os.Getenv("TERM") | ||
| 53 | kittyWindow := os.Getenv("KITTY_WINDOW_ID") | 56 | kittyWindow := os.Getenv("KITTY_WINDOW_ID") | ||
| 54 | 57 | ||||
| 58 | + | // VS Code's integrated terminal exposes several protocol-ish env | |||
| 59 | + | // combinations depending on the underlying shell/pty, but its image | |||
| 60 | + | // layer is inconsistent enough that auto-enable is more annoying than | |||
| 61 | + | // useful. Users can still force a protocol via ZOT_INLINE_IMAGES. | |||
| 62 | + | if strings.EqualFold(termProgram, "vscode") { | |||
| 63 | + | return ImageProtocolNone | |||
| 64 | + | } | |||
| 65 | + | ||||
| 55 | if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") { | 66 | if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") { | ||
| 56 | return ImageProtocolKitty | 67 | return ImageProtocolKitty | ||
| 57 | } | 68 | } | ||
| @@ -128,8 +139,10 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 128 | hdr := "a=T,f=100" | 139 | hdr := "a=T,f=100" | ||
| 129 | if maxCellsWide > 0 && maxCellsHigh > 0 { | 140 | if maxCellsWide > 0 && maxCellsHigh > 0 { | ||
| 130 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | 141 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | ||
| 131 | - | // rows that the native width would produce at maxCellsWide | 142 | + | // rows that the native width would produce at maxCellsWide. |
| 132 | - | nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio) | 143 | + | // Round up so we don't under-reserve and let following text |
| 144 | + | // paint over the image on terminals whose cell metrics differ. | |||
| 145 | + | nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio())) | |||
| 133 | if nativeRows > maxCellsHigh { | 146 | if nativeRows > maxCellsHigh { | ||
| 134 | hdr += fmt.Sprintf(",r=%d", maxCellsHigh) | 147 | hdr += fmt.Sprintf(",r=%d", maxCellsHigh) | ||
| 135 | } else { | 148 | } else { | ||
| @@ -172,11 +185,27 @@ func ImageDimensions(data []byte) (int, int) { | |||||
| 172 | return cfg.Width, cfg.Height | 185 | return cfg.Width, cfg.Height | ||
| 173 | } | 186 | } | ||
| 174 | 187 | ||||
| 175 | - | // CellAspectRatio approximates how many pixel-rows one terminal row | 188 | + | // defaultCellAspectRatio approximates how many pixel-rows one terminal |
| 176 | - | // occupies. Typical monospace cells are ~2x tall as wide; we use 2.0 | 189 | + | // row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0 |
| 177 | - | // as a safe default. Used to compute the rendered row count when | 190 | + | // as a safe default. Used to compute the rendered row count when scaling |
| 178 | - | // scaling an image to fit a cell width. | 191 | + | // an image to fit a cell width. |
| 179 | - | const CellAspectRatio = 2.0 | 192 | + | const defaultCellAspectRatio = 2.0 |
| 193 | + | ||||
| 194 | + | // CellAspectRatio returns the pixel-height / pixel-width ratio for one | |||
| 195 | + | // terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row | |||
| 196 | + | // reservation for terminals/fonts where the default causes overlap or | |||
| 197 | + | // excessive blank space. Values outside a sane range are ignored. | |||
| 198 | + | func CellAspectRatio() float64 { | |||
| 199 | + | v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT")) | |||
| 200 | + | if v == "" { | |||
| 201 | + | return defaultCellAspectRatio | |||
| 202 | + | } | |||
| 203 | + | f, err := strconv.ParseFloat(v, 64) | |||
| 204 | + | if err != nil || f < 0.5 || f > 5 { | |||
| 205 | + | return defaultCellAspectRatio | |||
| 206 | + | } | |||
| 207 | + | return f | |||
| 208 | + | } | |||
| 180 | 209 | ||||
| 181 | // RowsForInlineImage returns the number of terminal rows an image | 210 | // RowsForInlineImage returns the number of terminal rows an image | ||
| 182 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | 211 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | ||
| @@ -189,8 +218,10 @@ func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | |||||
| 189 | // pixels per cell (horizontal) | 218 | // pixels per cell (horizontal) | ||
| 190 | // scaleX = imageWidthPx / cellsWide | 219 | // scaleX = imageWidthPx / cellsWide | ||
| 191 | // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio) | 220 | // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio) | ||
| 221 | + | // Round up: under-reserving by even one row is much worse than one | |||
| 222 | + | // extra blank line, because following text can overwrite the image. | |||
| 192 | scaleX := float64(w) / float64(cellsWide) | 223 | scaleX := float64(w) / float64(cellsWide) | ||
| 193 | - | rows := int(float64(h) / (scaleX * CellAspectRatio)) | 224 | + | rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio()))) |
| 194 | if rows < 1 { | 225 | if rows < 1 { | ||
| 195 | rows = 1 | 226 | rows = 1 | ||
| 196 | } | 227 | } | ||
A internal/tui/image_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "image" | |
| 6 | + | "image/color" | |
| 7 | + | "image/png" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func testPNG(t *testing.T, w, h int) []byte { | |
| 12 | + | t.Helper() | |
| 13 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |
| 14 | + | for y := 0; y < h; y++ { | |
| 15 | + | for x := 0; x < w; x++ { | |
| 16 | + | img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | var buf bytes.Buffer | |
| 20 | + | if err := png.Encode(&buf, img); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | return buf.Bytes() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestRowsForInlineImageRoundsUp(t *testing.T) { | |
| 27 | + | t.Setenv("ZOT_CELL_ASPECT", "") | |
| 28 | + | data := testPNG(t, 100, 51) | |
| 29 | + | got := RowsForInlineImage(data, 10, 0) | |
| 30 | + | // 51px high at 10 cells wide with a 2.0 cell aspect is 2.55 rows. | |
| 31 | + | // Rounding down to 2 lets following text overlap; we need 3. | |
| 32 | + | if got != 3 { | |
| 33 | + | t.Fatalf("RowsForInlineImage = %d, want 3", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestRowsForInlineImageCellAspectOverride(t *testing.T) { | |
| 38 | + | data := testPNG(t, 100, 100) | |
| 39 | + | t.Setenv("ZOT_CELL_ASPECT", "1") | |
| 40 | + | if got := RowsForInlineImage(data, 10, 0); got != 10 { | |
| 41 | + | t.Fatalf("aspect=1 rows = %d, want 10", got) | |
| 42 | + | } | |
| 43 | + | t.Setenv("ZOT_CELL_ASPECT", "4") | |
| 44 | + | if got := RowsForInlineImage(data, 10, 0); got != 3 { | |
| 45 | + | t.Fatalf("aspect=4 rows = %d, want 3", got) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestDetectImageProtocolPlaceholderAndVSCode(t *testing.T) { | |
| 50 | + | t.Setenv("ZOT_INLINE_IMAGES", "placeholder") | |
| 51 | + | if got := DetectImageProtocol(); got != ImageProtocolNone { | |
| 52 | + | t.Fatalf("placeholder protocol = %v, want none", got) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | t.Setenv("ZOT_INLINE_IMAGES", "") | |
| 56 | + | t.Setenv("TERM_PROGRAM", "vscode") | |
| 57 | + | t.Setenv("KITTY_WINDOW_ID", "1") | |
| 58 | + | if got := DetectImageProtocol(); got != ImageProtocolNone { | |
| 59 | + | t.Fatalf("vscode auto protocol = %v, want none", got) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | t.Setenv("ZOT_INLINE_IMAGES", "kitty") | |
| 63 | + | if got := DetectImageProtocol(); got != ImageProtocolKitty { | |
| 64 | + | t.Fatalf("forced kitty protocol = %v, want kitty", got) | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "image" | |||
| 6 | + | "image/color" | |||
| 7 | + | "image/png" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func testPNG(t *testing.T, w, h int) []byte { | |||
| 12 | + | t.Helper() | |||
| 13 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |||
| 14 | + | for y := 0; y < h; y++ { | |||
| 15 | + | for x := 0; x < w; x++ { | |||
| 16 | + | img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | var buf bytes.Buffer | |||
| 20 | + | if err := png.Encode(&buf, img); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | return buf.Bytes() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestRowsForInlineImageRoundsUp(t *testing.T) { | |||
| 27 | + | t.Setenv("ZOT_CELL_ASPECT", "") | |||
| 28 | + | data := testPNG(t, 100, 51) | |||
| 29 | + | got := RowsForInlineImage(data, 10, 0) | |||
| 30 | + | // 51px high at 10 cells wide with a 2.0 cell aspect is 2.55 rows. | |||
| 31 | + | // Rounding down to 2 lets following text overlap; we need 3. | |||
| 32 | + | if got != 3 { | |||
| 33 | + | t.Fatalf("RowsForInlineImage = %d, want 3", got) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestRowsForInlineImageCellAspectOverride(t *testing.T) { | |||
| 38 | + | data := testPNG(t, 100, 100) | |||
| 39 | + | t.Setenv("ZOT_CELL_ASPECT", "1") | |||
| 40 | + | if got := RowsForInlineImage(data, 10, 0); got != 10 { | |||
| 41 | + | t.Fatalf("aspect=1 rows = %d, want 10", got) | |||
| 42 | + | } | |||
| 43 | + | t.Setenv("ZOT_CELL_ASPECT", "4") | |||
| 44 | + | if got := RowsForInlineImage(data, 10, 0); got != 3 { | |||
| 45 | + | t.Fatalf("aspect=4 rows = %d, want 3", got) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestDetectImageProtocolPlaceholderAndVSCode(t *testing.T) { | |||
| 50 | + | t.Setenv("ZOT_INLINE_IMAGES", "placeholder") | |||
| 51 | + | if got := DetectImageProtocol(); got != ImageProtocolNone { | |||
| 52 | + | t.Fatalf("placeholder protocol = %v, want none", got) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | t.Setenv("ZOT_INLINE_IMAGES", "") | |||
| 56 | + | t.Setenv("TERM_PROGRAM", "vscode") | |||
| 57 | + | t.Setenv("KITTY_WINDOW_ID", "1") | |||
| 58 | + | if got := DetectImageProtocol(); got != ImageProtocolNone { | |||
| 59 | + | t.Fatalf("vscode auto protocol = %v, want none", got) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | t.Setenv("ZOT_INLINE_IMAGES", "kitty") | |||
| 63 | + | if got := DetectImageProtocol(); got != ImageProtocolKitty { | |||
| 64 | + | t.Fatalf("forced kitty protocol = %v, want kitty", got) | |||
| 65 | + | } | |||
| 66 | + | } | |||
M internal/tui/view.go +6 -10
| @@ -882,21 +882,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 882 | 882 | if rows < 1 { | |
| 883 | 883 | rows = 1 | |
| 884 | 884 | } | |
| 885 | + | // Reserve the image footprint first, then place the escape | |
| 886 | + | // in the top-left of that blank rectangle. Keeping the | |
| 887 | + | // metadata above the image made some terminals blend text and | |
| 888 | + | // graphics layers during full-screen repaints; metadata below | |
| 889 | + | // the reserved rectangle is more stable and easier to read. | |
| 885 | 890 | out := make([]string, 0, rows+3) | |
| 886 | 891 | out = append(out, " "+seq) | |
| 887 | - | // Reserve blank rows matching the image's on-screen | |
| 888 | - | // height, plus one extra. RowsForInlineImage estimates | |
| 889 | - | // the height from a fixed cell aspect ratio; real | |
| 890 | - | // terminals differ enough that the image often spills | |
| 891 | - | // one row past the estimate and repaints over the info | |
| 892 | - | // line below. The extra row absorbs that spillover. | |
| 893 | - | for i := 1; i < rows+1; i++ { | |
| 892 | + | for i := 1; i < rows; i++ { | |
| 894 | 893 | out = append(out, "") | |
| 895 | 894 | } | |
| 896 | 895 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | |
| 897 | - | // Trailing blank so the closing rule / next block has | |
| 898 | - | // some breathing room under the info line instead of | |
| 899 | - | // sitting flush against it. | |
| 900 | 896 | out = append(out, "") | |
| 901 | 897 | return out | |
| 902 | 898 | } | |
| @@ -882,21 +882,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||||
| 882 | if rows < 1 { | 882 | if rows < 1 { | ||
| 883 | rows = 1 | 883 | rows = 1 | ||
| 884 | } | 884 | } | ||
| 885 | + | // Reserve the image footprint first, then place the escape | |||
| 886 | + | // in the top-left of that blank rectangle. Keeping the | |||
| 887 | + | // metadata above the image made some terminals blend text and | |||
| 888 | + | // graphics layers during full-screen repaints; metadata below | |||
| 889 | + | // the reserved rectangle is more stable and easier to read. | |||
| 885 | out := make([]string, 0, rows+3) | 890 | out := make([]string, 0, rows+3) | ||
| 886 | out = append(out, " "+seq) | 891 | out = append(out, " "+seq) | ||
| 887 | - | // Reserve blank rows matching the image's on-screen | 892 | + | for i := 1; i < rows; i++ { |
| 888 | - | // height, plus one extra. RowsForInlineImage estimates | |||
| 889 | - | // the height from a fixed cell aspect ratio; real | |||
| 890 | - | // terminals differ enough that the image often spills | |||
| 891 | - | // one row past the estimate and repaints over the info | |||
| 892 | - | // line below. The extra row absorbs that spillover. | |||
| 893 | - | for i := 1; i < rows+1; i++ { | |||
| 894 | out = append(out, "") | 893 | out = append(out, "") | ||
| 895 | } | 894 | } | ||
| 896 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | 895 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | ||
| 897 | - | // Trailing blank so the closing rule / next block has | |||
| 898 | - | // some breathing room under the info line instead of | |||
| 899 | - | // sitting flush against it. | |||
| 900 | out = append(out, "") | 896 | out = append(out, "") | ||
| 901 | return out | 897 | return out | ||
| 902 | } | 898 | } | ||