diff --git a/CHANGELOG.md b/CHANGELOG.md index b29fccef..762a1d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/terminal.go b/src/terminal.go index e0054236..00f0b2bb 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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, ""}) diff --git a/src/terminal_test.go b/src/terminal_test.go index 0ece0179..4bd65d9f 100644 --- a/src/terminal_test.go +++ b/src/terminal_test.go @@ -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]) + } + } + } +}