fix(tui): VS Code ghost highlight on selection navigation
Forces full attribute reset before clearing each row when selection highlights are present. VS Code's xterm.js doesn't reliably clear background colors on row overwrite without an explicit reset.
- commit
- 487b097da40376bb8504fd476e3cbb1bf1987363
- author
- patriceckhart · · 2026-04-25 18:49 UTC
- parent
- 6237dd2
- signature
- unsigned
- refs
- tag: v0.1.16
2 files · +23 -5
M internal/tui/render.go +22 -1
| @@ -198,10 +198,31 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 198 | 198 | } | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | + | // Detect selection highlights: if the current OR previous frame | |
| 202 | + | // has selection-background rows, force full repaint. VS Code's | |
| 203 | + | // terminal doesn't reliably clear background colors on row | |
| 204 | + | // overwrites, leaving ghost highlights behind. | |
| 205 | + | hasSelection := false | |
| 206 | + | for _, l := range frame { | |
| 207 | + | if strings.Contains(l, "\x1b[48;5;") { | |
| 208 | + | hasSelection = true | |
| 209 | + | break | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | if !hasSelection && r.prev != nil { | |
| 213 | + | for _, l := range r.prev { | |
| 214 | + | if strings.Contains(l, "\x1b[48;5;") { | |
| 215 | + | hasSelection = true | |
| 216 | + | break | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 201 | 221 | full := r.prev == nil || len(r.prev) != r.rows | |
| 202 | 222 | for i := 0; i < r.rows; i++ { | |
| 203 | - | if full || forceAll || r.prev[i] != frame[i] { | |
| 223 | + | if full || forceAll || hasSelection || r.prev[i] != frame[i] { | |
| 204 | 224 | w.WriteString(MoveTo(i+1, 1)) | |
| 225 | + | w.WriteString("\x1b[0m") // reset all attributes first | |
| 205 | 226 | w.WriteString(SeqClearLine) | |
| 206 | 227 | w.WriteString(frame[i]) | |
| 207 | 228 | } | |
| @@ -198,10 +198,31 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 198 | } | 198 | } | ||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | + | // Detect selection highlights: if the current OR previous frame | |||
| 202 | + | // has selection-background rows, force full repaint. VS Code's | |||
| 203 | + | // terminal doesn't reliably clear background colors on row | |||
| 204 | + | // overwrites, leaving ghost highlights behind. | |||
| 205 | + | hasSelection := false | |||
| 206 | + | for _, l := range frame { | |||
| 207 | + | if strings.Contains(l, "\x1b[48;5;") { | |||
| 208 | + | hasSelection = true | |||
| 209 | + | break | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | if !hasSelection && r.prev != nil { | |||
| 213 | + | for _, l := range r.prev { | |||
| 214 | + | if strings.Contains(l, "\x1b[48;5;") { | |||
| 215 | + | hasSelection = true | |||
| 216 | + | break | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 201 | full := r.prev == nil || len(r.prev) != r.rows | 221 | full := r.prev == nil || len(r.prev) != r.rows | ||
| 202 | for i := 0; i < r.rows; i++ { | 222 | for i := 0; i < r.rows; i++ { | ||
| 203 | - | if full || forceAll || r.prev[i] != frame[i] { | 223 | + | if full || forceAll || hasSelection || r.prev[i] != frame[i] { |
| 204 | w.WriteString(MoveTo(i+1, 1)) | 224 | w.WriteString(MoveTo(i+1, 1)) | ||
| 225 | + | w.WriteString("\x1b[0m") // reset all attributes first | |||
| 205 | w.WriteString(SeqClearLine) | 226 | w.WriteString(SeqClearLine) | ||
| 206 | w.WriteString(frame[i]) | 227 | w.WriteString(frame[i]) | ||
| 207 | } | 228 | } | ||
M internal/tui/theme.go +1 -4
| @@ -72,10 +72,7 @@ func (t Theme) PadHighlight(s string, width int) string { | |||
| 72 | 72 | if visible < width { | |
| 73 | 73 | s += strings.Repeat(" ", width-visible) | |
| 74 | 74 | } | |
| 75 | - | // Emit the background color AFTER the reset via a trailing | |
| 76 | - | // erase-to-end-of-line so the highlight extends to the terminal | |
| 77 | - | // edge even if the cell count is slightly off. | |
| 78 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + "\x1b[K" + reset | |
| 75 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset | |
| 79 | 76 | } | |
| 80 | 77 | ||
| 81 | 78 | // Bold wraps s in bold SGR. | |
| @@ -72,10 +72,7 @@ func (t Theme) PadHighlight(s string, width int) string { | |||||
| 72 | if visible < width { | 72 | if visible < width { | ||
| 73 | s += strings.Repeat(" ", width-visible) | 73 | s += strings.Repeat(" ", width-visible) | ||
| 74 | } | 74 | } | ||
| 75 | - | // Emit the background color AFTER the reset via a trailing | 75 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset |
| 76 | - | // erase-to-end-of-line so the highlight extends to the terminal | |||
| 77 | - | // edge even if the cell count is slightly off. | |||
| 78 | - | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + "\x1b[K" + reset | |||
| 79 | } | 76 | } | ||
| 80 | 77 | ||||
| 81 | // Bold wraps s in bold SGR. | 78 | // Bold wraps s in bold SGR. | ||