Separate redraws from view-line invalidation

A view can need repainting even when its cached wrapping is still valid.
Track that state independently so content-only flushes do not overload
tainted, whose only job is to request a viewLines rebuild.
This commit is contained in:
Stefan Haller
2026-10-01 17:28:30 +02:00
parent a65311fd50
commit 7db724918a
2 changed files with 27 additions and 7 deletions
+4 -4
View File
@@ -1598,10 +1598,10 @@ func (g *Gui) flush() error {
return nil
}
// Redraws only tainted views and skips the layout pass.
// Redraws only dirty views and skips the layout pass.
// tcell's cell-level dirty tracking ensures only
// actually-changed cells are emitted to the terminal.
// Will also redraw any views that overlap tainted views
// Will also redraw any views that overlap dirty views.
func (g *Gui) flushContentOnly(views []*View) {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
@@ -1619,7 +1619,7 @@ func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
if !v.NeedsRedraw() && !redrawIndexes.Includes(i) {
continue
}
@@ -1649,7 +1649,7 @@ func (g *Gui) ForceLayoutAndRedraw() error {
return g.flush()
}
// Redraws only tainted views outside of the normal main
// Redraws only dirty views outside of the normal main
// loop, without a layout pass. Useful during longer operations that block the
// main thread, e.g. to update a spinner in a status view.
func (g *Gui) ForceFlushViewsContentOnly(views []*View) {
+23 -3
View File
@@ -86,6 +86,11 @@ type View struct {
// tained is true if the viewLines must be updated
tainted bool
// needsRedraw is true if the view's current state has not been drawn to the
// screen yet. A tainted view always needs a redraw, but draw-only state can
// require one without invalidating viewLines.
needsRedraw bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to since viewLines was last refreshed, and whose cached wrapping
// (lineType.wrappedCells) may therefore be stale. Lines below it are
@@ -274,11 +279,18 @@ type pos struct {
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
func (v *View) clearViewLines() {
v.tainted = true
v.markViewLinesDirty()
v.viewLines = nil
v.clearHover()
}
// markViewLinesDirty records that the cached viewLines no longer represent the
// view's buffer or wrapping, so both rebuilding and redrawing are required.
func (v *View) markViewLinesDirty() {
v.tainted = true
v.needsRedraw = true
}
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
@@ -793,6 +805,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
Frame: true,
Editor: DefaultEditor,
tainted: true,
needsRedraw: true,
outMode: mode,
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
@@ -1177,7 +1190,7 @@ func (v *View) write(p []byte) {
return
}
v.tainted = true
v.markViewLinesDirty()
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
@@ -1612,7 +1625,7 @@ func (v *View) SwapInOffscreenRender() {
}
v.buf = v.offscreen
v.offscreen = nil
v.tainted = true
v.markViewLinesDirty()
v.clearHover()
}
@@ -1776,6 +1789,12 @@ func (v *View) IsTainted() bool {
return v.tainted
}
func (v *View) NeedsRedraw() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.needsRedraw
}
// draw re-draws the view's contents.
func (v *View) draw(isWindowFocused bool) {
v.writeMutex.Lock()
@@ -1784,6 +1803,7 @@ func (v *View) draw(isWindowFocused bool) {
if !v.Visible {
return
}
defer func() { v.needsRedraw = false }()
v.clearRunes()