Wrap long edit diff lines
- commit
- 734bf87c9c178aba726e72a7c4597445d46986c3
- author
- patriceckhart · · 2026-07-23 12:23 UTC
- parent
- 09b92ac
- signature
- unsigned
2 files · +146 -54
M packages/tui/view.go +77 -54
| @@ -1549,15 +1549,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 1549 | 1549 | if inDiff && len(l) > 0 { | |
| 1550 | 1550 | switch l[0] { | |
| 1551 | 1551 | case '+': | |
| 1552 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) | |
| 1552 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...) | |
| 1553 | 1553 | newLine++ | |
| 1554 | 1554 | continue | |
| 1555 | 1555 | case '-': | |
| 1556 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) | |
| 1556 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...) | |
| 1557 | 1557 | oldLine++ | |
| 1558 | 1558 | continue | |
| 1559 | 1559 | case ' ': | |
| 1560 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) | |
| 1560 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...) | |
| 1561 | 1561 | oldLine++ | |
| 1562 | 1562 | newLine++ | |
| 1563 | 1563 | continue | |
| @@ -1613,34 +1613,17 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |||
| 1613 | 1613 | return o, n, true | |
| 1614 | 1614 | } | |
| 1615 | 1615 | ||
| 1616 | - | // renderDiffRow renders one unified-diff line with a read-style gutter | |
| 1617 | - | // (6-cell right-aligned line number, muted) followed by the +/-/space | |
| 1618 | - | // marker and the code. Code is syntax-highlighted if sourcePath hints | |
| 1619 | - | // at a known language; falls back to the plain diff color otherwise. | |
| 1620 | - | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string { | |
| 1616 | + | // renderDiffRow renders one unified-diff source line as one or more visual | |
| 1617 | + | // rows. The first row carries the read-style line-number gutter; continuation | |
| 1618 | + | // rows use a blank gutter so wrapped content cannot be mistaken for another | |
| 1619 | + | // source line. Code is syntax-highlighted if sourcePath hints at a known | |
| 1620 | + | // language and otherwise uses the diff color. | |
| 1621 | + | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string { | |
| 1621 | 1622 | if len(line) == 0 { | |
| 1622 | - | return "" | |
| 1623 | + | return []string{""} | |
| 1623 | 1624 | } | |
| 1624 | 1625 | code := expandTabs(line[1:]) // strip the leading marker; expand tabs | |
| 1625 | 1626 | ||
| 1626 | - | // Syntax-highlight the code half when we know the language. Use | |
| 1627 | - | // the same HighlightCode pipeline as renderNumberedFile so the | |
| 1628 | - | // palette matches. | |
| 1629 | - | lang := LanguageFromPath(sourcePath) | |
| 1630 | - | var codeRendered string | |
| 1631 | - | if lang != "" { | |
| 1632 | - | if h := v.Theme.HighlightCode(code, lang); len(h) == 1 { | |
| 1633 | - | codeRendered = h[0] | |
| 1634 | - | } | |
| 1635 | - | } | |
| 1636 | - | if codeRendered == "" { | |
| 1637 | - | if mark == ' ' { | |
| 1638 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, code) | |
| 1639 | - | } else { | |
| 1640 | - | codeRendered = v.Theme.FG256(color, code) | |
| 1641 | - | } | |
| 1642 | - | } | |
| 1643 | - | ||
| 1644 | 1627 | // Gutter shape: sign + number share a color so they read as one | |
| 1645 | 1628 | // visual token ("+123") instead of a neutral line number next to | |
| 1646 | 1629 | // a stray marker. Unchanged context lines get a muted gutter and | |
| @@ -1655,39 +1638,79 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||
| 1655 | 1638 | default: | |
| 1656 | 1639 | gutterText = fmt.Sprintf(" %3d ", lineNo) | |
| 1657 | 1640 | } | |
| 1658 | - | var gutter string | |
| 1641 | + | gutterColor := color | |
| 1659 | 1642 | if mark == ' ' { | |
| 1660 | - | gutter = v.Theme.FG256(v.Theme.Muted, gutterText) | |
| 1661 | - | } else { | |
| 1662 | - | gutter = v.Theme.FG256(color, gutterText) | |
| 1643 | + | gutterColor = v.Theme.Muted | |
| 1663 | 1644 | } | |
| 1664 | - | row := " " + gutter + codeRendered | |
| 1665 | 1645 | ||
| 1666 | - | // Cheap width clamp: truncate visible text if the raw code is too | |
| 1667 | - | // long. We work on the pre-ANSI code string because measuring ansi | |
| 1668 | - | // output is unreliable. | |
| 1669 | - | maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */ | |
| 1670 | - | if maxCode > 0 && len(code) > maxCode { | |
| 1671 | - | trunc := strings.Repeat(".", maxCode) | |
| 1672 | - | if maxCode > 3 { | |
| 1673 | - | trunc = code[:maxCode-3] + "..." | |
| 1646 | + | maxCode := width - visibleWidth(" "+gutterText) | |
| 1647 | + | if maxCode < 1 { | |
| 1648 | + | maxCode = 1 | |
| 1649 | + | } | |
| 1650 | + | chunks := wrapCodeLine(code, maxCode) | |
| 1651 | + | lang := LanguageFromPath(sourcePath) | |
| 1652 | + | out := make([]string, 0, len(chunks)) | |
| 1653 | + | for i, chunk := range chunks { | |
| 1654 | + | rowGutter := gutterText | |
| 1655 | + | if i > 0 { | |
| 1656 | + | rowGutter = strings.Repeat(" ", visibleWidth(gutterText)) | |
| 1674 | 1657 | } | |
| 1658 | + | codeRendered := "" | |
| 1675 | 1659 | if lang != "" { | |
| 1676 | - | if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 { | |
| 1660 | + | if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 { | |
| 1677 | 1661 | codeRendered = h[0] | |
| 1678 | - | } else if mark == ' ' { | |
| 1679 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) | |
| 1680 | - | } else { | |
| 1681 | - | codeRendered = v.Theme.FG256(color, trunc) | |
| 1682 | 1662 | } | |
| 1683 | - | } else if mark == ' ' { | |
| 1684 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) | |
| 1685 | - | } else { | |
| 1686 | - | codeRendered = v.Theme.FG256(color, trunc) | |
| 1687 | 1663 | } | |
| 1688 | - | row = " " + gutter + codeRendered | |
| 1664 | + | if codeRendered == "" { | |
| 1665 | + | codeColor := color | |
| 1666 | + | if mark == ' ' { | |
| 1667 | + | codeColor = v.Theme.Muted | |
| 1668 | + | } | |
| 1669 | + | codeRendered = v.Theme.FG256(codeColor, chunk) | |
| 1670 | + | } | |
| 1671 | + | out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered) | |
| 1672 | + | } | |
| 1673 | + | return out | |
| 1674 | + | } | |
| 1675 | + | ||
| 1676 | + | // wrapCodeLine splits code to a terminal cell width without dropping any | |
| 1677 | + | // characters. It prefers breaking after whitespace, then falls back to a hard | |
| 1678 | + | // rune boundary for long tokens. Keeping the whitespace on the preceding row | |
| 1679 | + | // means joining the returned chunks reconstructs the expanded source exactly. | |
| 1680 | + | func wrapCodeLine(line string, width int) []string { | |
| 1681 | + | if width < 1 || visibleWidth(line) <= width { | |
| 1682 | + | return []string{line} | |
| 1683 | + | } | |
| 1684 | + | runes := []rune(line) | |
| 1685 | + | var out []string | |
| 1686 | + | for start := 0; start < len(runes); { | |
| 1687 | + | cells := 0 | |
| 1688 | + | end := start | |
| 1689 | + | lastBreak := -1 | |
| 1690 | + | for end < len(runes) { | |
| 1691 | + | runeWidth := runewidth.RuneWidth(runes[end]) | |
| 1692 | + | if cells+runeWidth > width && end > start { | |
| 1693 | + | break | |
| 1694 | + | } | |
| 1695 | + | cells += runeWidth | |
| 1696 | + | end++ | |
| 1697 | + | if runes[end-1] == ' ' || runes[end-1] == '\t' { | |
| 1698 | + | lastBreak = end | |
| 1699 | + | } | |
| 1700 | + | if cells >= width { | |
| 1701 | + | break | |
| 1702 | + | } | |
| 1703 | + | } | |
| 1704 | + | if end == start { // A rune wider than the requested width. | |
| 1705 | + | end++ | |
| 1706 | + | } | |
| 1707 | + | if end < len(runes) && lastBreak > start { | |
| 1708 | + | end = lastBreak | |
| 1709 | + | } | |
| 1710 | + | out = append(out, string(runes[start:end])) | |
| 1711 | + | start = end | |
| 1689 | 1712 | } | |
| 1690 | - | return row | |
| 1713 | + | return out | |
| 1691 | 1714 | } | |
| 1692 | 1715 | ||
| 1693 | 1716 | // renderImageBlock returns the lines for one image, inline if possible. | |
| @@ -2048,13 +2071,13 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||
| 2048 | 2071 | } | |
| 2049 | 2072 | switch l[0] { | |
| 2050 | 2073 | case '+': | |
| 2051 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) | |
| 2074 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...) | |
| 2052 | 2075 | newLine++ | |
| 2053 | 2076 | case '-': | |
| 2054 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) | |
| 2077 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...) | |
| 2055 | 2078 | oldLine++ | |
| 2056 | 2079 | case ' ': | |
| 2057 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) | |
| 2080 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...) | |
| 2058 | 2081 | oldLine++ | |
| 2059 | 2082 | newLine++ | |
| 2060 | 2083 | default: | |
| @@ -1549,15 +1549,15 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||||
| 1549 | if inDiff && len(l) > 0 { | 1549 | if inDiff && len(l) > 0 { | ||
| 1550 | switch l[0] { | 1550 | switch l[0] { | ||
| 1551 | case '+': | 1551 | case '+': | ||
| 1552 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) | 1552 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...) |
| 1553 | newLine++ | 1553 | newLine++ | ||
| 1554 | continue | 1554 | continue | ||
| 1555 | case '-': | 1555 | case '-': | ||
| 1556 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) | 1556 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...) |
| 1557 | oldLine++ | 1557 | oldLine++ | ||
| 1558 | continue | 1558 | continue | ||
| 1559 | case ' ': | 1559 | case ' ': | ||
| 1560 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) | 1560 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...) |
| 1561 | oldLine++ | 1561 | oldLine++ | ||
| 1562 | newLine++ | 1562 | newLine++ | ||
| 1563 | continue | 1563 | continue | ||
| @@ -1613,34 +1613,17 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |||||
| 1613 | return o, n, true | 1613 | return o, n, true | ||
| 1614 | } | 1614 | } | ||
| 1615 | 1615 | ||||
| 1616 | - | // renderDiffRow renders one unified-diff line with a read-style gutter | 1616 | + | // renderDiffRow renders one unified-diff source line as one or more visual |
| 1617 | - | // (6-cell right-aligned line number, muted) followed by the +/-/space | 1617 | + | // rows. The first row carries the read-style line-number gutter; continuation |
| 1618 | - | // marker and the code. Code is syntax-highlighted if sourcePath hints | 1618 | + | // rows use a blank gutter so wrapped content cannot be mistaken for another |
| 1619 | - | // at a known language; falls back to the plain diff color otherwise. | 1619 | + | // source line. Code is syntax-highlighted if sourcePath hints at a known |
| 1620 | - | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string { | 1620 | + | // language and otherwise uses the diff color. |
| 1621 | + | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string { | |||
| 1621 | if len(line) == 0 { | 1622 | if len(line) == 0 { | ||
| 1622 | - | return "" | 1623 | + | return []string{""} |
| 1623 | } | 1624 | } | ||
| 1624 | code := expandTabs(line[1:]) // strip the leading marker; expand tabs | 1625 | code := expandTabs(line[1:]) // strip the leading marker; expand tabs | ||
| 1625 | 1626 | ||||
| 1626 | - | // Syntax-highlight the code half when we know the language. Use | |||
| 1627 | - | // the same HighlightCode pipeline as renderNumberedFile so the | |||
| 1628 | - | // palette matches. | |||
| 1629 | - | lang := LanguageFromPath(sourcePath) | |||
| 1630 | - | var codeRendered string | |||
| 1631 | - | if lang != "" { | |||
| 1632 | - | if h := v.Theme.HighlightCode(code, lang); len(h) == 1 { | |||
| 1633 | - | codeRendered = h[0] | |||
| 1634 | - | } | |||
| 1635 | - | } | |||
| 1636 | - | if codeRendered == "" { | |||
| 1637 | - | if mark == ' ' { | |||
| 1638 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, code) | |||
| 1639 | - | } else { | |||
| 1640 | - | codeRendered = v.Theme.FG256(color, code) | |||
| 1641 | - | } | |||
| 1642 | - | } | |||
| 1643 | - | ||||
| 1644 | // Gutter shape: sign + number share a color so they read as one | 1627 | // Gutter shape: sign + number share a color so they read as one | ||
| 1645 | // visual token ("+123") instead of a neutral line number next to | 1628 | // visual token ("+123") instead of a neutral line number next to | ||
| 1646 | // a stray marker. Unchanged context lines get a muted gutter and | 1629 | // a stray marker. Unchanged context lines get a muted gutter and | ||
| @@ -1655,39 +1638,79 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||||
| 1655 | default: | 1638 | default: | ||
| 1656 | gutterText = fmt.Sprintf(" %3d ", lineNo) | 1639 | gutterText = fmt.Sprintf(" %3d ", lineNo) | ||
| 1657 | } | 1640 | } | ||
| 1658 | - | var gutter string | 1641 | + | gutterColor := color |
| 1659 | if mark == ' ' { | 1642 | if mark == ' ' { | ||
| 1660 | - | gutter = v.Theme.FG256(v.Theme.Muted, gutterText) | 1643 | + | gutterColor = v.Theme.Muted |
| 1661 | - | } else { | |||
| 1662 | - | gutter = v.Theme.FG256(color, gutterText) | |||
| 1663 | } | 1644 | } | ||
| 1664 | - | row := " " + gutter + codeRendered | |||
| 1665 | 1645 | ||||
| 1666 | - | // Cheap width clamp: truncate visible text if the raw code is too | 1646 | + | maxCode := width - visibleWidth(" "+gutterText) |
| 1667 | - | // long. We work on the pre-ANSI code string because measuring ansi | 1647 | + | if maxCode < 1 { |
| 1668 | - | // output is unreliable. | 1648 | + | maxCode = 1 |
| 1669 | - | maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */ | 1649 | + | } |
| 1670 | - | if maxCode > 0 && len(code) > maxCode { | 1650 | + | chunks := wrapCodeLine(code, maxCode) |
| 1671 | - | trunc := strings.Repeat(".", maxCode) | 1651 | + | lang := LanguageFromPath(sourcePath) |
| 1672 | - | if maxCode > 3 { | 1652 | + | out := make([]string, 0, len(chunks)) |
| 1673 | - | trunc = code[:maxCode-3] + "..." | 1653 | + | for i, chunk := range chunks { |
| 1654 | + | rowGutter := gutterText | |||
| 1655 | + | if i > 0 { | |||
| 1656 | + | rowGutter = strings.Repeat(" ", visibleWidth(gutterText)) | |||
| 1674 | } | 1657 | } | ||
| 1658 | + | codeRendered := "" | |||
| 1675 | if lang != "" { | 1659 | if lang != "" { | ||
| 1676 | - | if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 { | 1660 | + | if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 { |
| 1677 | codeRendered = h[0] | 1661 | codeRendered = h[0] | ||
| 1678 | - | } else if mark == ' ' { | |||
| 1679 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) | |||
| 1680 | - | } else { | |||
| 1681 | - | codeRendered = v.Theme.FG256(color, trunc) | |||
| 1682 | } | 1662 | } | ||
| 1683 | - | } else if mark == ' ' { | |||
| 1684 | - | codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) | |||
| 1685 | - | } else { | |||
| 1686 | - | codeRendered = v.Theme.FG256(color, trunc) | |||
| 1687 | } | 1663 | } | ||
| 1688 | - | row = " " + gutter + codeRendered | 1664 | + | if codeRendered == "" { |
| 1665 | + | codeColor := color | |||
| 1666 | + | if mark == ' ' { | |||
| 1667 | + | codeColor = v.Theme.Muted | |||
| 1668 | + | } | |||
| 1669 | + | codeRendered = v.Theme.FG256(codeColor, chunk) | |||
| 1670 | + | } | |||
| 1671 | + | out = append(out, " "+v.Theme.FG256(gutterColor, rowGutter)+codeRendered) | |||
| 1672 | + | } | |||
| 1673 | + | return out | |||
| 1674 | + | } | |||
| 1675 | + | ||||
| 1676 | + | // wrapCodeLine splits code to a terminal cell width without dropping any | |||
| 1677 | + | // characters. It prefers breaking after whitespace, then falls back to a hard | |||
| 1678 | + | // rune boundary for long tokens. Keeping the whitespace on the preceding row | |||
| 1679 | + | // means joining the returned chunks reconstructs the expanded source exactly. | |||
| 1680 | + | func wrapCodeLine(line string, width int) []string { | |||
| 1681 | + | if width < 1 || visibleWidth(line) <= width { | |||
| 1682 | + | return []string{line} | |||
| 1683 | + | } | |||
| 1684 | + | runes := []rune(line) | |||
| 1685 | + | var out []string | |||
| 1686 | + | for start := 0; start < len(runes); { | |||
| 1687 | + | cells := 0 | |||
| 1688 | + | end := start | |||
| 1689 | + | lastBreak := -1 | |||
| 1690 | + | for end < len(runes) { | |||
| 1691 | + | runeWidth := runewidth.RuneWidth(runes[end]) | |||
| 1692 | + | if cells+runeWidth > width && end > start { | |||
| 1693 | + | break | |||
| 1694 | + | } | |||
| 1695 | + | cells += runeWidth | |||
| 1696 | + | end++ | |||
| 1697 | + | if runes[end-1] == ' ' || runes[end-1] == '\t' { | |||
| 1698 | + | lastBreak = end | |||
| 1699 | + | } | |||
| 1700 | + | if cells >= width { | |||
| 1701 | + | break | |||
| 1702 | + | } | |||
| 1703 | + | } | |||
| 1704 | + | if end == start { // A rune wider than the requested width. | |||
| 1705 | + | end++ | |||
| 1706 | + | } | |||
| 1707 | + | if end < len(runes) && lastBreak > start { | |||
| 1708 | + | end = lastBreak | |||
| 1709 | + | } | |||
| 1710 | + | out = append(out, string(runes[start:end])) | |||
| 1711 | + | start = end | |||
| 1689 | } | 1712 | } | ||
| 1690 | - | return row | 1713 | + | return out |
| 1691 | } | 1714 | } | ||
| 1692 | 1715 | ||||
| 1693 | // renderImageBlock returns the lines for one image, inline if possible. | 1716 | // renderImageBlock returns the lines for one image, inline if possible. | ||
| @@ -2048,13 +2071,13 @@ func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []st | |||||
| 2048 | } | 2071 | } | ||
| 2049 | switch l[0] { | 2072 | switch l[0] { | ||
| 2050 | case '+': | 2073 | case '+': | ||
| 2051 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) | 2074 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...) |
| 2052 | newLine++ | 2075 | newLine++ | ||
| 2053 | case '-': | 2076 | case '-': | ||
| 2054 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) | 2077 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...) |
| 2055 | oldLine++ | 2078 | oldLine++ | ||
| 2056 | case ' ': | 2079 | case ' ': | ||
| 2057 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) | 2080 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...) |
| 2058 | oldLine++ | 2081 | oldLine++ | ||
| 2059 | newLine++ | 2082 | newLine++ | ||
| 2060 | default: | 2083 | default: | ||
A packages/tui/view_diff_wrap_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRenderDiffRowWrapsWithoutLosingContent(t *testing.T) { | |
| 9 | + | const width = 30 | |
| 10 | + | code := `const message = "alpha beta gamma delta epsilon"` | |
| 11 | + | v := View{Theme: Dark} | |
| 12 | + | ||
| 13 | + | rows := v.renderDiffRow("+"+code, width, v.Theme.Tool, 7, '+', "sample.go") | |
| 14 | + | if len(rows) < 2 { | |
| 15 | + | t.Fatalf("long diff line did not wrap: %q", rows) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | var rebuilt strings.Builder | |
| 19 | + | for i, row := range rows { | |
| 20 | + | plain := stripANSI(row) | |
| 21 | + | if got := visibleWidth(row); got > width { | |
| 22 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain) | |
| 23 | + | } | |
| 24 | + | prefix := " " | |
| 25 | + | if i == 0 { | |
| 26 | + | prefix = " + 7 " | |
| 27 | + | } | |
| 28 | + | if !strings.HasPrefix(plain, prefix) { | |
| 29 | + | t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix) | |
| 30 | + | } | |
| 31 | + | rebuilt.WriteString(strings.TrimPrefix(plain, prefix)) | |
| 32 | + | } | |
| 33 | + | if got := rebuilt.String(); got != code { | |
| 34 | + | t.Fatalf("wrapped content changed:\n got: %q\nwant: %q", got, code) | |
| 35 | + | } | |
| 36 | + | if strings.Contains(stripANSI(strings.Join(rows, "\n")), "...") { | |
| 37 | + | t.Fatalf("wrapped diff still contains truncation ellipsis: %q", rows) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestRenderDiffRowWrapsUnicodeByDisplayWidth(t *testing.T) { | |
| 42 | + | const width = 18 | |
| 43 | + | code := "名前 = 🙂🙂🙂 café résumé" | |
| 44 | + | v := View{Theme: Dark} | |
| 45 | + | ||
| 46 | + | rows := v.renderDiffRow("-"+code, width, v.Theme.Error, 12, '-', "") | |
| 47 | + | if len(rows) < 2 { | |
| 48 | + | t.Fatalf("long Unicode diff line did not wrap: %q", rows) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | var rebuilt strings.Builder | |
| 52 | + | for i, row := range rows { | |
| 53 | + | plain := stripANSI(row) | |
| 54 | + | if got := visibleWidth(row); got > width { | |
| 55 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain) | |
| 56 | + | } | |
| 57 | + | prefix := " " | |
| 58 | + | if i == 0 { | |
| 59 | + | prefix = " - 12 " | |
| 60 | + | } | |
| 61 | + | if !strings.HasPrefix(plain, prefix) { | |
| 62 | + | t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix) | |
| 63 | + | } | |
| 64 | + | rebuilt.WriteString(strings.TrimPrefix(plain, prefix)) | |
| 65 | + | } | |
| 66 | + | if got := rebuilt.String(); got != code { | |
| 67 | + | t.Fatalf("wrapped Unicode content changed:\n got: %q\nwant: %q", got, code) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRenderDiffRowWrapsWithoutLosingContent(t *testing.T) { | |||
| 9 | + | const width = 30 | |||
| 10 | + | code := `const message = "alpha beta gamma delta epsilon"` | |||
| 11 | + | v := View{Theme: Dark} | |||
| 12 | + | ||||
| 13 | + | rows := v.renderDiffRow("+"+code, width, v.Theme.Tool, 7, '+', "sample.go") | |||
| 14 | + | if len(rows) < 2 { | |||
| 15 | + | t.Fatalf("long diff line did not wrap: %q", rows) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | var rebuilt strings.Builder | |||
| 19 | + | for i, row := range rows { | |||
| 20 | + | plain := stripANSI(row) | |||
| 21 | + | if got := visibleWidth(row); got > width { | |||
| 22 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain) | |||
| 23 | + | } | |||
| 24 | + | prefix := " " | |||
| 25 | + | if i == 0 { | |||
| 26 | + | prefix = " + 7 " | |||
| 27 | + | } | |||
| 28 | + | if !strings.HasPrefix(plain, prefix) { | |||
| 29 | + | t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix) | |||
| 30 | + | } | |||
| 31 | + | rebuilt.WriteString(strings.TrimPrefix(plain, prefix)) | |||
| 32 | + | } | |||
| 33 | + | if got := rebuilt.String(); got != code { | |||
| 34 | + | t.Fatalf("wrapped content changed:\n got: %q\nwant: %q", got, code) | |||
| 35 | + | } | |||
| 36 | + | if strings.Contains(stripANSI(strings.Join(rows, "\n")), "...") { | |||
| 37 | + | t.Fatalf("wrapped diff still contains truncation ellipsis: %q", rows) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRenderDiffRowWrapsUnicodeByDisplayWidth(t *testing.T) { | |||
| 42 | + | const width = 18 | |||
| 43 | + | code := "名前 = 🙂🙂🙂 café résumé" | |||
| 44 | + | v := View{Theme: Dark} | |||
| 45 | + | ||||
| 46 | + | rows := v.renderDiffRow("-"+code, width, v.Theme.Error, 12, '-', "") | |||
| 47 | + | if len(rows) < 2 { | |||
| 48 | + | t.Fatalf("long Unicode diff line did not wrap: %q", rows) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | var rebuilt strings.Builder | |||
| 52 | + | for i, row := range rows { | |||
| 53 | + | plain := stripANSI(row) | |||
| 54 | + | if got := visibleWidth(row); got > width { | |||
| 55 | + | t.Errorf("row %d width = %d, want <= %d: %q", i, got, width, plain) | |||
| 56 | + | } | |||
| 57 | + | prefix := " " | |||
| 58 | + | if i == 0 { | |||
| 59 | + | prefix = " - 12 " | |||
| 60 | + | } | |||
| 61 | + | if !strings.HasPrefix(plain, prefix) { | |||
| 62 | + | t.Fatalf("row %d prefix = %q, want %q", i, plain, prefix) | |||
| 63 | + | } | |||
| 64 | + | rebuilt.WriteString(strings.TrimPrefix(plain, prefix)) | |||
| 65 | + | } | |||
| 66 | + | if got := rebuilt.String(); got != code { | |||
| 67 | + | t.Fatalf("wrapped Unicode content changed:\n got: %q\nwant: %q", got, code) | |||
| 68 | + | } | |||
| 69 | + | } | |||