1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
|
package main
import (
"bufio"
"fmt"
"os/exec"
"strings"
"unicode/utf8"
"github.com/charmbracelet/x/ansi"
)
// ColorizedText stores ANSI-escaped text pre-split into lines for efficient access.
// Used for syntax-highlighted source code and markdown content throughout the TUI.
type ColorizedText struct {
lines []string // Pre-split lines (no newlines) for O(1) line access
}
// NewColorizedText creates ColorizedText from ANSI-escaped string.
// Splits on newlines and removes trailing empty line if present.
func NewColorizedText(ansiText string) ColorizedText {
lines := strings.Split(ansiText, "\n")
// Remove trailing empty line if present (from trailing newline)
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return ColorizedText{lines: lines}
}
// ColorizeFileWithBat colorizes a file using bat and returns a line-indexed map.
// Uses bat with --force-colorization for syntax highlighting. Map keys are 1-based line numbers.
func ColorizeFileWithBat(filename string) (map[int]ColorizedText, error) {
cmd := exec.Command("bat", "--force-colorization", "-pp", "--theme=ansi", "--tabs=8", filename)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("bat failed: %w", err)
}
colorizedLines := make(map[int]ColorizedText)
scanner := bufio.NewScanner(strings.NewReader(string(output)))
lineNum := 1
for scanner.Scan() {
colorizedLines[lineNum] = NewColorizedText(scanner.Text())
lineNum++
}
return colorizedLines, nil
}
// ColorizeTextWithBat colorizes plain text using bat with specified language.
// Used for syntax highlighting commit details (gitlog) and help text (markdown).
func ColorizeTextWithBat(plainText, language string) (ColorizedText, error) {
batCmd := exec.Command("bat", "--force-colorization", "-pp", "--language="+language, "--theme=ansi")
batCmd.Stdin = strings.NewReader(plainText)
batOutput, err := batCmd.Output()
if err != nil {
return ColorizedText{}, fmt.Errorf("bat failed: %w", err)
}
return NewColorizedText(string(batOutput)), nil
}
// LineCount returns the number of lines
func (ct ColorizedText) LineCount() int {
return len(ct.lines)
}
// GetLine returns the specified line (0-indexed)
func (ct ColorizedText) GetLine(lineNum int) string {
if lineNum < 0 || lineNum >= len(ct.lines) {
return ""
}
return ct.lines[lineNum]
}
// GetLineTruncated returns the line truncated to maxWidth with ellipsis
func (ct ColorizedText) GetLineTruncated(lineNum int, maxWidth int) string {
line := ct.GetLine(lineNum)
return ansi.Truncate(line, maxWidth, "…")
}
// GetLineWithoutBackgrounds strips background colors from the line
func (ct ColorizedText) GetLineWithoutBackgrounds(lineNum int) string {
line := ct.GetLine(lineNum)
// Replace full reset (which resets both foreground and background)
// with foreground-only reset to preserve syntax highlighting
return strings.ReplaceAll(line, "\x1b[0m", "\x1b[39m")
}
// LineWidth returns the visible width of the specified line
func (ct ColorizedText) LineWidth(lineNum int) int {
line := ct.GetLine(lineNum)
return ansi.StringWidth(line)
}
// GetLineRange returns lines [start, end) joined with newlines
func (ct ColorizedText) GetLineRange(start, end int) string {
if start < 0 {
start = 0
}
if end > len(ct.lines) {
end = len(ct.lines)
}
if start >= end {
return ""
}
return strings.Join(ct.lines[start:end], "\n")
}
// findTextStart returns the index of the first text character in a line,
// skipping whitespace and common marker characters (-, *, >, |, #, etc.)
func findTextStart(line string) int {
// Strip ANSI codes for analysis
plainLine := ansi.Strip(line)
idx := 0
for idx < len(plainLine) {
ch := plainLine[idx]
// Skip whitespace
if ch == ' ' || ch == '\t' {
idx++
continue
}
// Skip common markdown/list markers
if ch == '-' || ch == '*' || ch == '>' || ch == '|' || ch == '#' || ch == '`' {
idx++
continue
}
// Found text start
break
}
return idx
}
// wrapTextWithLineNumbers takes ColorizedText and wraps long lines while adding line numbers
// Wrapped continuation lines are indented to match the first line's text start position
func wrapTextWithLineNumbers(ct ColorizedText, maxWidth int) ColorizedText {
if ct.LineCount() == 0 {
return ct
}
// Calculate line number width (same as main screen)
lineNumWidth := len(fmt.Sprintf("%d", ct.LineCount()))
// Grey color for line numbers (same as main screen)
greyColor := "\x1b[38;5;" + string(ColorTextDim) + "m"
resetColor := "\x1b[0m"
// Available width for actual text (subtract line number column)
textWidth := max(
// -1 for space after number
maxWidth-lineNumWidth-1, 20)
var wrappedLines []string
for lineNum := 0; lineNum < ct.LineCount(); lineNum++ {
line := ct.GetLine(lineNum)
lineWidth := ansi.StringWidth(line)
// Format line number (right-aligned with dot leader padding, same as main screen)
lineNumDigits := fmt.Sprintf("%d", lineNum+1)
paddingNeeded := lineNumWidth - len(lineNumDigits)
lineNumStr := strings.Repeat("․", paddingNeeded) + lineNumDigits
linePrefix := greyColor + lineNumStr + resetColor + " "
if lineWidth <= textWidth {
// Line fits, just prepend line number
wrappedLines = append(wrappedLines, linePrefix+line)
} else {
// Line needs wrapping
// Find where text starts (after markers like -, *, etc.)
textStartPos := findTextStart(line)
// Create indentation for continuation lines (spaces to match text start)
contIndent := strings.Repeat(" ", textStartPos)
contPrefix := strings.Repeat(" ", lineNumWidth+1) + contIndent
// Wrap the line
remaining := line
first := true
for ansi.StringWidth(remaining) > 0 {
var prefix string
var availWidth int
if first {
prefix = linePrefix
availWidth = textWidth
first = false
} else {
prefix = contPrefix
availWidth = max(textWidth-textStartPos, 10)
}
// Extract a chunk that fits
chunk := ansi.Truncate(remaining, availWidth, "")
chunkWidth := ansi.StringWidth(chunk)
wrappedLines = append(wrappedLines, prefix+chunk)
// Remove processed chunk from remaining
if chunkWidth >= ansi.StringWidth(remaining) {
break
}
// Get the plain text versions to find how many characters to skip
plainChunk := ansi.Strip(chunk)
charsToSkip := len([]rune(plainChunk))
// Skip that many visible runes in remaining (accounting for ANSI codes)
runesSkipped := 0
bytePos := 0
for bytePos < len(remaining) && runesSkipped < charsToSkip {
// Skip ANSI escape sequences
if bytePos < len(remaining) && remaining[bytePos] == '\x1b' {
// Find the end of the escape sequence (ends with 'm')
for bytePos < len(remaining) {
bytePos++
if remaining[bytePos-1] == 'm' {
break
}
}
continue
}
// Count this visible rune
_, size := utf8.DecodeRuneInString(remaining[bytePos:])
bytePos += size
runesSkipped++
}
remaining = remaining[bytePos:]
}
}
}
return ColorizedText{lines: wrappedLines}
}
|