Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 80 additions & 21 deletions cursed_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,24 @@ import (
)

type cursedRenderer struct {
w io.Writer
buf bytes.Buffer // updates buffer to be flushed to [w]
scr *uv.TerminalRenderer
cellbuf uv.ScreenBuffer
lastView *View
env []string
term string // the terminal type $TERM
width, height int
mu sync.Mutex
profile colorprofile.Profile
logger uv.Logger
view View
hardTabs bool // whether to use hard tabs to optimize cursor movements
backspace bool // whether to use backspace to optimize cursor movements
mapnl bool
syncdUpdates bool // whether to use synchronized output mode for updates
starting bool // indicates whether the renderer is starting after being stopped
w io.Writer
buf bytes.Buffer // updates buffer to be flushed to [w]
scr *uv.TerminalRenderer
cellbuf uv.ScreenBuffer
lastView *View
env []string
term string // the terminal type $TERM
width, height int
mu sync.Mutex
profile colorprofile.Profile
logger uv.Logger
view View
hardTabs bool // whether to use hard tabs to optimize cursor movements
backspace bool // whether to use backspace to optimize cursor movements
mapnl bool
syncdUpdates bool // whether to use synchronized output mode for updates
starting bool // indicates whether the renderer is starting after being stopped
lastContentLines []string // previous frame's View lines for scroll detection
}

var _ renderer = &cursedRenderer{}
Expand Down Expand Up @@ -303,12 +304,69 @@ func (s *cursedRenderer) flush(closing bool) error {
// and to avoid rendering issues when the frame area is smaller than
// the screen buffer.
s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy())

// Clear after resize so stale cells at newly-visible positions don't
// persist if content doesn't cover them.
s.cellbuf.Clear()

// Invalidate scroll detection state after resize: dimensions changed so
// previous content lines don't correspond to new buffer geometry.
s.lastContentLines = nil
}

// Clear our screen buffer before copying the new frame into it to ensure
// we erase any old content.
s.cellbuf.Clear()
content.Draw(s.cellbuf, s.cellbuf.Bounds())
// Detect content scroll and shift cellbuf lines so DrawOver finds
// matching cells for preserved lines. Only the N new lines get marked
// as touched, so transformLine skips the shifted lines entirely.
//
// When no shift is detected, fall back to the original Clear+Draw path
// to avoid stale cells from DrawOver (printString doesn't clear cells
// beyond each line's content, so shorter lines would leave artifacts).
newLines := strings.Split(view.Content, "\n")
if shift, regionStart, matchCount := detectContentShift(s.lastContentLines, newLines); shift != 0 {
absShift := shift
if absShift < 0 {
absShift = -absShift
}
regionEnd := regionStart + matchCount + absShift
shiftCellbufRegion(&s.cellbuf, regionStart, regionEnd, shift)
s.scr.HardScroll(s.cellbuf.RenderBuffer, shift, regionStart, regionEnd-1)

// Clear lines outside the shifted region that changed.
for i := regionEnd; i < len(newLines) && i < len(s.lastContentLines); i++ {
if newLines[i] != s.lastContentLines[i] {
clearCellbufLine(&s.cellbuf, i)
}
}

width := s.cellbuf.Width()
height := s.cellbuf.Height()
drawStart := regionStart + matchCount

if shift > 0 {
changedContent := strings.Join(newLines[drawStart:], "\n")
partial := uv.NewStyledString(changedContent)
partial.DrawOver(s.cellbuf, uv.Rect(0, drawStart, width, height))
} else {
topEnd := regionStart + absShift
topContent := strings.Join(newLines[regionStart:topEnd], "\n")
top := uv.NewStyledString(topContent)
top.DrawOver(s.cellbuf, uv.Rect(0, regionStart, width, topEnd))
if regionEnd < len(newLines) {
bottomContent := strings.Join(newLines[regionEnd:], "\n")
bottom := uv.NewStyledString(bottomContent)
bottom.DrawOver(s.cellbuf, uv.Rect(0, regionEnd, width, height))
}
}

contentHeight := strings.Count(view.Content, "\n") + 1
if contentHeight < height {
s.cellbuf.ClearArea(uv.Rect(0, contentHeight, width, height))
}
} else {
s.cellbuf.Clear()
content.Draw(s.cellbuf, s.cellbuf.Bounds())
}
s.lastContentLines = newLines

// If the frame height is greater than the screen height, we drop the
// lines from the top of the buffer.
Expand Down Expand Up @@ -592,6 +650,7 @@ func (s *cursedRenderer) reset() {

func reset(s *cursedRenderer) {
s.buf.Reset()
s.lastContentLines = nil
scr := uv.NewTerminalRenderer(&s.buf, s.env)
scr.SetColorProfile(s.profile)
scr.SetRelativeCursor(true) // Always start in inline mode
Expand Down
244 changes: 244 additions & 0 deletions cursed_renderer_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package tea

import (
"fmt"
"io"
"strings"
"testing"

uv "github.com/charmbracelet/ultraviolet"
)

// generateStyledContent creates ANSI-styled content simulating a TUI viewport.
// Each line has foreground color, bold text, and a reset — similar to real TUI output.
func generateStyledContent(width, height, offset int) string {
var sb strings.Builder
for y := 0; y < height; y++ {
lineNum := offset + y
prefix := fmt.Sprintf("\x1b[38;2;200;200;200m\x1b[48;2;30;30;46m%4d │ ", lineNum)
body := fmt.Sprintf("Line content for row %d with some text that fills the width", lineNum)
visibleLen := 7 + len(body)
if visibleLen < width {
body += strings.Repeat(" ", width-visibleLen)
} else if visibleLen > width {
body = body[:width-7]
}
sb.WriteString(prefix)
sb.WriteString(body)
sb.WriteString("\x1b[0m")
if y < height-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}

func BenchmarkFlushScroll(b *testing.B) {
const width, height = 200, 50
const scrollStep = 3

env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
r := newCursedRenderer(io.Discard, env, width, height)
r.syncdUpdates = false

view := View{
Content: generateStyledContent(width, height, 0),
AltScreen: true,
}
r.render(view)
_ = r.flush(false)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
offset := (i + 1) * scrollStep
view.Content = generateStyledContent(width, height, offset)
r.render(view)
_ = r.flush(false)
}
}

func BenchmarkFlushStatic(b *testing.B) {
const width, height = 200, 50

env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
r := newCursedRenderer(io.Discard, env, width, height)
r.syncdUpdates = false

view := View{
Content: generateStyledContent(width, height, 0),
AltScreen: true,
}
r.render(view)
_ = r.flush(false)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
r.render(view)
_ = r.flush(false)
}
}

func BenchmarkFlushFullChange(b *testing.B) {
const width, height = 200, 50

env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
r := newCursedRenderer(io.Discard, env, width, height)
r.syncdUpdates = false

view := View{
Content: generateStyledContent(width, height, 0),
AltScreen: true,
}
r.render(view)
_ = r.flush(false)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
view.Content = generateStyledContent(width, height, (i+1)*height)
r.render(view)
_ = r.flush(false)
}
}

// generatePartialScrollContent creates content simulating steiner's layout:
// topChromeLines constant lines at top, contentLines scrolling, bottomChromeLines
// constant at bottom. This mirrors real TUI apps that have padding/header at the
// top and status/input at the bottom.
func generatePartialScrollContent(width, topChromeLines, contentLines, bottomChromeLines, offset int) string {
var sb strings.Builder
height := topChromeLines + contentLines + bottomChromeLines
for y := 0; y < height; y++ {
var line string
if y < topChromeLines {
line = fmt.Sprintf("\x1b[48;2;30;30;46m%s\x1b[0m", strings.Repeat(" ", width))
} else if y < topChromeLines+contentLines {
lineNum := offset + y - topChromeLines
prefix := fmt.Sprintf("\x1b[38;2;200;200;200m\x1b[48;2;30;30;46m%4d │ ", lineNum)
body := fmt.Sprintf("Line content for row %d with some text", lineNum)
visibleLen := 7 + len(body)
if visibleLen < width {
body += strings.Repeat(" ", width-visibleLen)
}
line = prefix + body + "\x1b[0m"
} else {
switch y - topChromeLines - contentLines {
case 0:
line = strings.Repeat("─", width)
case 1:
line = fmt.Sprintf("> %s", strings.Repeat(" ", width-2))
default:
line = fmt.Sprintf("\x1b[7m steiner \x1b[0m%s", strings.Repeat(" ", width-9))
}
}
sb.WriteString(line)
if y < height-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}

func BenchmarkFlushScrollPartial(b *testing.B) {
const width = 200
const topChrome = 1
const contentLines = 39
const bottomChrome = 10
const scrollStep = 3

height := topChrome + contentLines + bottomChrome
env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
r := newCursedRenderer(io.Discard, env, width, height)
r.syncdUpdates = false

view := View{
Content: generatePartialScrollContent(width, topChrome, contentLines, bottomChrome, 0),
AltScreen: true,
}
r.render(view)
_ = r.flush(false)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
offset := (i + 1) * scrollStep
view.Content = generatePartialScrollContent(width, topChrome, contentLines, bottomChrome, offset)
r.render(view)
_ = r.flush(false)
}
}

// generateSuffixContent creates ANSI-styled content where each line ends with a
// scrollbar character that varies per frame (thumbPos controls which line gets
// the thumb). This simulates a viewport with a live scrollbar: the content
// prefix is stable across frames so prefix-match fires, but exact equality
// fails because the thumb position shifts.
func generateSuffixContent(width, height, offset int, thumbPos int) string {
var sb strings.Builder
for y := 0; y < height; y++ {
lineNum := offset + y
prefix := fmt.Sprintf("\x1b[38;2;200;200;200m\x1b[48;2;30;30;46m%4d │ ", lineNum)
body := fmt.Sprintf("Line content for row %d with some text that fills the width", lineNum)
visibleLen := 7 + len(body)
if visibleLen < width-3 {
body += strings.Repeat(" ", width-3-visibleLen)
}
var scrollChar string
if y == thumbPos {
scrollChar = "\x1b[7m▓\x1b[0m" // reverse-video thumb
} else {
scrollChar = "│"
}
sb.WriteString(prefix)
sb.WriteString(body)
sb.WriteString(scrollChar)
sb.WriteString("\x1b[0m")
if y < height-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}

func BenchmarkFlushScrollWithSuffix(b *testing.B) {
const width, height = 200, 50
const scrollStep = 3

env := []string{"TERM=xterm-256color", "COLORTERM=truecolor"}
r := newCursedRenderer(io.Discard, env, width, height)
r.syncdUpdates = false

view := View{
Content: generateSuffixContent(width, height, 0, 25),
AltScreen: true,
}
r.render(view)
_ = r.flush(false)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
offset := (i + 1) * scrollStep
thumbPos := (offset * height / (height * 10)) % height // moves slowly
view.Content = generateSuffixContent(width, height, offset, thumbPos)
r.render(view)
_ = r.flush(false)
}
}

func BenchmarkDrawOnly(b *testing.B) {
const width, height = 200, 50

content := generateStyledContent(width, height, 0)
cellbuf := uv.NewScreenBuffer(width, height)

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
cellbuf.Clear()
s := uv.NewStyledString(content)
s.Draw(cellbuf, cellbuf.Bounds())
}
}
Loading