Break preview lines on IND to support chafa in preview window on tmux

chafa ends each row of a Kitty Unicode placeholder image with CUB + IND
instead of a newline, so that an image drawn at a column offset survives
ONLCR, which would rewrite the newline as CR NL and pull the cursor back
to column 0. Both sequences were dropped, so the image arrived as a single
line and was re-wrapped, shifting every row after the first by the width
of the wrap sign. Without wrapping it was truncated to its first row.

The column is tracked across the breaks and restored with padding, so an
image indented by the preview command keeps its indent.

Fix #4885
This commit is contained in:
Junegunn Choi
2026-08-07 22:02:55 +09:00
parent 3337be9d45
commit 0579bb0e0d
3 changed files with 123 additions and 1 deletions
+4
View File
@@ -1,6 +1,10 @@
CHANGELOG
=========
0.74.3
------
- Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885)
0.74.2
------
- Performance optimizations for short queries
+49 -1
View File
@@ -58,6 +58,7 @@ var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp
var cursorBackRegex *regexp.Regexp
var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0
@@ -96,6 +97,9 @@ func init() {
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
// CUB right before an IND, used to return to the column a row started on
cursorBackRegex = regexp.MustCompile(`\x1b\[([0-9]*)D$`)
}
type jumpMode int
@@ -2276,6 +2280,14 @@ func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
return width
}
// displayWidthWithoutEscapes is displayWidthWithPrefix for a string that may
// still carry pass-throughs and ANSI codes, neither of which take any column.
func (t *Terminal) displayWidthWithoutEscapes(str string, prefixWidth int) int {
_, text := extractPassThroughs(str)
stripped, _, _ := extractColor(text, nil, nil)
return t.displayWidthWithPrefix(stripped, prefixWidth)
}
const (
minWidth = 4
minHeight = 3
@@ -4849,6 +4861,38 @@ func extractPassThroughs(line string) ([]string, string) {
return passThroughs, transformed
}
// splitOnIND breaks a preview line on IND (ESC D), which moves the cursor
// down one line, keeping the column. A program drawing at a column offset ends
// its rows with IND instead of a newline, because ONLCR would rewrite a newline
// as CR NL and snap the cursor to column 0. chafa does this for Kitty Unicode
// placeholders, and without the break the whole image collapses into a single
// line.
//
// The column is tracked and re-created with padding so that an indented image
// keeps its indent, and a CUB right before the IND is subtracted, which is how
// chafa returns to the column its rows start on.
func (t *Terminal) splitOnIND(line string) []string {
chunks := strings.Split(line, "\x1bD")
if len(chunks) == 1 {
return nil
}
lines := make([]string, 0, len(chunks))
col := 0
for _, chunk := range chunks[:len(chunks)-1] {
lines = append(lines, strings.Repeat(" ", col)+chunk+"\n")
col += t.displayWidthWithoutEscapes(chunk, col)
if match := cursorBackRegex.FindStringSubmatch(chunk); match != nil {
back := 1
if len(match[1]) > 0 {
back, _ = strconv.Atoi(match[1])
}
col = max(0, col-back)
}
}
return append(lines, strings.Repeat(" ", col)+chunks[len(chunks)-1])
}
// followOffset computes the correct content-line offset for follow mode,
// accounting for line wrapping in the preview window.
func (t *Terminal) followOffset() int {
@@ -6421,7 +6465,11 @@ func (t *Terminal) Loop() error {
version--
offset = 0
}
lines = append(lines, line)
if split := t.splitOnIND(line); split != nil {
lines = append(lines, split...)
} else {
lines = append(lines, line)
}
}
if err != nil {
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
+70
View File
@@ -851,3 +851,73 @@ func TestWordWrapAnsiLine(t *testing.T) {
t.Errorf("Tab wrap: %q", result)
}
}
func TestSplitOnIND(t *testing.T) {
term := &Terminal{tabstop: 8}
for _, tc := range []struct {
name string
line string
want []string
}{
{
// Nothing to do, so the caller keeps the line as it read it
name: "no IND",
line: "foo\n",
want: nil,
},
{
// chafa: CUB returns to the column the row started on
name: "rows at column 0",
line: "AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"AAA\x1b[3D\n", "BBB\x1b[3D\n", "CCC\n"},
},
{
// 'printf " "; chafa ...'
name: "indented rows",
line: " AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{" AAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
name: "tab indent expands to the tab stop",
line: "\tAAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"\tAAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
// IND on its own keeps the column
name: "bare IND",
line: "AB\x1bDCD\n",
want: []string{"AB\n", " CD\n"},
},
{
name: "CUB without a parameter moves back one",
line: "AB\x1b[D\x1bDCD\n",
want: []string{"AB\x1b[D\n", " CD\n"},
},
{
name: "CUB past the left edge is clamped",
line: "AB\x1b[9D\x1bDCD\n",
want: []string{"AB\x1b[9D\n", "CD\n"},
},
{
name: "SGR codes take no column",
line: "\x1b[31mAB\x1b[m\x1b[2D\x1bDCD\n",
want: []string{"\x1b[31mAB\x1b[m\x1b[2D\n", "CD\n"},
},
{
name: "pass-throughs take no column",
line: "\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\x1bDCD\n",
want: []string{"\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\n", "CD\n"},
},
} {
got := term.splitOnIND(tc.line)
if len(got) != len(tc.want) {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
continue
}
for idx, line := range got {
if line != tc.want[idx] {
t.Errorf("%s: line %d: got %q, want %q", tc.name, idx, line, tc.want[idx])
}
}
}
}