current commit
261022.4 · perf(cell): reuse the drawn width when putting back the same text
// Copyright 2026 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package tcell

import "testing"

func TestCellBufferPutAndSanitize(t *testing.T) {
	t.Run("unsanitized", func(t *testing.T) {
		cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4)}
		rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault)
		if rest != "\x1bB" {
			t.Fatalf("unexpected remainder: %q", rest)
		}
		if width != 1 {
			t.Fatalf("unexpected width: %d", width)
		}
		if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 {
			t.Fatalf("unexpected cell content: %q width=%d", got, gotWidth)
		}
	})

	t.Run("sanitized", func(t *testing.T) {
		cb := &CellBuffer{w: 4, h: 1, cells: make([]cell, 4), sanitizeContent: true}
		rest, width := cb.Put(0, 0, "A\x1bB", StyleDefault)
		if rest != "B" {
			t.Fatalf("unexpected sanitized remainder: %q", rest)
		}
		if width != 1 {
			t.Fatalf("unexpected width: %d", width)
		}
		if got, _, gotWidth := cb.Get(0, 0); got != "A" || gotWidth != 1 {
			t.Fatalf("unexpected sanitized cell content: %q width=%d", got, gotWidth)
		}
	})
}

func TestCellBufferWideAndColorNone(t *testing.T) {
	cb := &CellBuffer{w: 3, h: 1, cells: make([]cell, 3)}

	base := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
	cb.Put(0, 0, "a", base)
	cb.SetDirty(0, 0, false)
	cb.Put(1, 0, "z", base)
	cb.SetDirty(1, 0, false)

	reused := Style{fg: ColorNone, bg: ColorNone}
	rest, width := cb.Put(0, 0, "你", reused)
	if rest != "" {
		t.Fatalf("unexpected remainder for wide rune: %q", rest)
	}
	if width != 2 {
		t.Fatalf("unexpected width for wide rune: %d", width)
	}
	if !cb.Dirty(0, 0) {
		t.Fatalf("wide rune should dirty the base cell")
	}
	if !cb.Dirty(1, 0) {
		t.Fatalf("wide rune should dirty the second cell")
	}

	got, gotStyle, gotWidth := cb.Get(0, 0)
	if got != "你" || gotWidth != 2 {
		t.Fatalf("unexpected wide cell content: %q width=%d", got, gotWidth)
	}
	if gotStyle.GetForeground() != ColorRed || gotStyle.GetBackground() != ColorBlue {
		t.Fatalf("ColorNone should preserve existing colors, got fg=%v bg=%v", gotStyle.GetForeground(), gotStyle.GetBackground())
	}
}

func TestCellBufferDirtyState(t *testing.T) {
	cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}

	cb.cells[0].setDirty(false)
	if got := cb.cells[0].lastStr; got != " " {
		t.Fatalf("setDirty(false) should normalize empty content to space, got %q", got)
	}
	if got := cb.cells[0].lastStyle; got != cb.cells[0].currStyle {
		t.Fatalf("setDirty(false) should copy style")
	}

	cb.cells[0].setDirty(true)
	if got := cb.cells[0].lastStr; got != "" {
		t.Fatalf("setDirty(true) should clear lastStr, got %q", got)
	}

	cb.Put(0, 0, "x", StyleDefault)
	cb.SetDirty(0, 0, false)
	if cb.Dirty(0, 0) {
		t.Fatalf("clean cell should not be dirty")
	}

	cb.cells[0].currStyle = cb.cells[0].currStyle.Bold(true)
	if !cb.Dirty(0, 0) {
		t.Fatalf("style change should make cell dirty")
	}
	cb.SetDirty(0, 0, false)
	cb.cells[0].currStyle = cb.cells[0].lastStyle
	cb.cells[0].currStr = "y"
	if !cb.Dirty(0, 0) {
		t.Fatalf("content change should make cell dirty")
	}
}

func TestCellBufferLockResizeFill(t *testing.T) {
	cb := &CellBuffer{w: 2, h: 2, cells: make([]cell, 4)}
	cb.Fill('x', StyleDefault)
	if got, _, _ := cb.Get(1, 1); got != "x" {
		t.Fatalf("unexpected fill content: %q", got)
	}

	cb.LockCell(1, 1)
	if cb.Dirty(1, 1) {
		t.Fatalf("locked cell should not be dirty")
	}
	cb.UnlockCell(1, 1)
	if !cb.Dirty(1, 1) {
		t.Fatalf("unlocked cell should be dirty")
	}

	cb.Put(0, 0, "ab", StyleDefault)
	cb.Resize(3, 1)
	if got, _, _ := cb.Get(0, 0); got != "a" {
		t.Fatalf("resize should preserve content, got %q", got)
	}
	if _, _, width := cb.Get(0, 0); width != 1 {
		t.Fatalf("unexpected width after resize: %d", width)
	}

	cb.Invalidate()
	if !cb.Dirty(0, 0) {
		t.Fatalf("invalidate should mark content dirty")
	}
}

func TestCellBufferUnlockUntouchedCellMarksDirty(t *testing.T) {
	cb := &CellBuffer{w: 1, h: 1, cells: make([]cell, 1)}

	cb.LockCell(0, 0)
	cb.UnlockCell(0, 0)

	if !cb.Dirty(0, 0) {
		t.Fatalf("unlocking an untouched blank cell should mark it dirty")
	}
}

func TestCellBufferOutOfRangeAndResizeNoop(t *testing.T) {
	cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}

	if rest, width := cb.Put(-1, 0, "x", StyleDefault); rest != "x" || width != 0 {
		t.Fatalf("out-of-range put should be a no-op, got rest=%q width=%d", rest, width)
	}

	cb.Put(0, 0, "x", StyleDefault.Foreground(ColorRed).Background(ColorBlue))
	cb.SetDirty(0, 0, false)

	if rest, width := cb.Put(0, 0, "", StyleDefault.Foreground(ColorGreen)); rest != "" || width != 0 {
		t.Fatalf("empty put should be a no-op, got rest=%q width=%d", rest, width)
	}
	afterStr, afterStyle, afterWidth := cb.Get(0, 0)
	if afterStr != " " || afterStyle.GetForeground() != ColorGreen || afterWidth != 1 {
		t.Fatalf("empty put should clear the cell and apply the new style: got=%q/%v/%d", afterStr, afterStyle, afterWidth)
	}
	if got := cb.Dirty(0, 0); !got {
		t.Fatalf("empty put should mark the cell dirty")
	}

	cb.LockCell(-1, 0)
	cb.LockCell(2, 0)
	cb.UnlockCell(-1, 0)
	cb.UnlockCell(2, 0)

	cb.Resize(2, 1)
	if w, h := cb.Size(); w != 2 || h != 1 {
		t.Fatalf("resize no-op changed size to %dx%d", w, h)
	}
}

func TestCellBufferPutRedraw(t *testing.T) {
	cb := &CellBuffer{w: 8, h: 1, cells: make([]cell, 8)}

	// The first Put measures the cell; a re-Put of identical content takes the
	// fast path and must return the same result without segmenting again.
	rest1, width1 := cb.Put(0, 0, "x", StyleDefault)
	rest2, width2 := cb.Put(0, 0, "x", StyleDefault)
	if rest2 != rest1 || width2 != width1 {
		t.Fatalf("re-Put mismatch: (%q,%d), want (%q,%d)", rest2, width2, rest1, width1)
	}

	allocs := testing.AllocsPerRun(100, func() {
		cb.Put(0, 0, "x", StyleDefault)
	})
	if allocs != 0 {
		t.Fatalf("re-Put allocated %.0f times, want 0", allocs)
	}
}

func TestCellBufferPutASCIIBeforeCombiningCluster(t *testing.T) {
	cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
	rest, width := cb.Put(0, 0, "xe\u0301", StyleDefault)
	if rest != "e\u0301" || width != 1 {
		t.Fatalf("Put returned (%q,%d), want (%q,1)", rest, width, "e\u0301")
	}

	rest, width = cb.Put(1, 0, rest, StyleDefault)
	if rest != "" || width != 1 {
		t.Fatalf("combining Put returned (%q,%d), want (\"\",1)", rest, width)
	}
	str, _, _ := cb.Get(1, 0)
	if str != "e\u0301" {
		t.Fatalf("combining cell is %q, want %q", str, "e\u0301")
	}
}

func TestCellBufferPutASCIIFastPathMatchesGraphemeIterator(t *testing.T) {
	inputs := make([]string, 0, 95*96)
	for first := byte(' '); first <= '~'; first++ {
		inputs = append(inputs, string(first))
		for second := byte(' '); second <= '~'; second++ {
			inputs = append(inputs, string([]byte{first, second}))
		}
	}

	for _, input := range inputs {
		graphemes := textWidthOptions.StringGraphemes(input)
		if !graphemes.Next() {
			t.Fatalf("grapheme iterator returned no value for %q", input)
		}
		wantString := graphemes.Value()
		wantWidth := graphemes.Width()
		wantRest := input[len(wantString):]

		cb := &CellBuffer{w: 1, h: 1, cells: make([]cell, 1)}
		gotRest, gotWidth := cb.Put(0, 0, input, StyleDefault)
		gotString, _, _ := cb.Get(0, 0)
		if gotString != wantString || gotWidth != wantWidth || gotRest != wantRest {
			t.Fatalf(
				"Put(%q) = (%q,%d,%q), want (%q,%d,%q)",
				input, gotString, gotWidth, gotRest, wantString, wantWidth, wantRest,
			)
		}
	}
}

func TestCellBufferFillArea(t *testing.T) {
	cb := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
	cb.Fill('.', StyleDefault)

	red := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
	cb.FillArea(1, 1, 2, 1, 'x', red)

	// Cells inside the region should have changed.
	for _, xy := range [][2]int{{1, 1}, {2, 1}} {
		got, style, width := cb.Get(xy[0], xy[1])
		if got != "x" || width != 1 || style != red {
			t.Fatalf("cell %v not filled: got %q style=%v width=%d", xy, got, style, width)
		}
	}
	// Neighbours just outside the region should be untouched.
	for _, xy := range [][2]int{{0, 1}, {3, 1}, {1, 0}, {1, 2}} {
		if got, _, _ := cb.Get(xy[0], xy[1]); got != "." {
			t.Fatalf("cell %v should be untouched, got %q", xy, got)
		}
	}
}

func TestCellBufferFillAreaClipping(t *testing.T) {
	cb := &CellBuffer{w: 3, h: 3, cells: make([]cell, 9)}
	cb.Fill('.', StyleDefault)

	// A region that starts off-screen and overflows the far edge should only
	// paint the cells that actually fall inside the buffer.
	cb.FillArea(-2, -2, 4, 4, '#', StyleDefault)
	for y := 0; y < 3; y++ {
		for x := 0; x < 3; x++ {
			want := "."
			if x < 2 && y < 2 {
				want = "#"
			}
			if got, _, _ := cb.Get(x, y); got != want {
				t.Fatalf("cell (%d,%d): got %q want %q", x, y, got, want)
			}
		}
	}

	// Zero and negative extents must not change anything.
	cb.FillArea(0, 0, 0, 3, '!', StyleDefault)
	cb.FillArea(0, 0, 3, -1, '!', StyleDefault)
	if got, _, _ := cb.Get(0, 0); got != "#" {
		t.Fatalf("empty region should be a no-op, got %q", got)
	}
}

func TestCellBufferFillAreaOverflow(t *testing.T) {
	const maxInt = int(^uint(0) >> 1)
	const minInt = -maxInt - 1

	// A width that overflows x+w must still fill every in-bounds cell from x
	// onward rather than clipping to a wrapped-around negative endpoint.
	cb := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
	cb.Fill('.', StyleDefault)
	cb.FillArea(1, 1, maxInt, maxInt, '#', StyleDefault)
	for y := 0; y < 3; y++ {
		for x := 0; x < 4; x++ {
			want := "."
			if x >= 1 && y >= 1 {
				want = "#"
			}
			if got, _, _ := cb.Get(x, y); got != want {
				t.Fatalf("cell (%d,%d): got %q want %q", x, y, got, want)
			}
		}
	}

	// Negative extents (including the extreme minimum) must never underflow
	// into filling cells; the region is empty and nothing changes.
	cb2 := &CellBuffer{w: 4, h: 3, cells: make([]cell, 12)}
	cb2.Fill('.', StyleDefault)
	cb2.FillArea(minInt+1, minInt+1, minInt, minInt, '#', StyleDefault)
	cb2.FillArea(0, 0, minInt, 2, '#', StyleDefault)
	for y := 0; y < 3; y++ {
		for x := 0; x < 4; x++ {
			if got, _, _ := cb2.Get(x, y); got != "." {
				t.Fatalf("cell (%d,%d) should be untouched, got %q", x, y, got)
			}
		}
	}
}

func TestCellBufferFillAreaColorNone(t *testing.T) {
	cb := &CellBuffer{w: 2, h: 1, cells: make([]cell, 2)}
	base := StyleDefault.Foreground(ColorRed).Background(ColorBlue)
	cb.Fill(' ', base)

	// ColorNone should leave the existing fg/bg in place, matching Fill.
	cb.FillArea(0, 0, 1, 1, 'y', StyleDefault.Foreground(ColorNone).Background(ColorNone))
	if _, style, _ := cb.Get(0, 0); style != base {
		t.Fatalf("ColorNone should preserve existing colors, got %v", style)
	}
}

func TestCellBufferRePutAfterClear(t *testing.T) {
	cb := &CellBuffer{}
	cb.Resize(4, 1)
	for _, str := range []string{"宽", "🎉", "👩‍👩‍👧‍👦", "e\u0301"} {
		_, want := cb.Put(0, 0, str, StyleDefault)
		cb.SetDirty(0, 0, false) // drawn

		// Putting back what is on screen after a Clear reuses the width
		// measured when it was drawn.
		cb.Put(0, 0, " ", StyleDefault) // what Clear leaves
		if rest, width := cb.Put(0, 0, str, StyleDefault); rest != "" || width != want {
			t.Errorf("%q: got %q, %d after clearing, want \"\", %d", str, rest, width, want)
		}
	}
}

func TestCellBufferRePutAfterRedrawMarker(t *testing.T) {
	cb := &CellBuffer{}
	cb.Resize(4, 1)
	cb.Put(0, 0, "宽", StyleDefault)
	cb.SetDirty(0, 0, false) // drawn, two columns wide

	// Marking the cell for redraw sets lastStr to "", which must not keep
	// the width of what was drawn.
	cb.SetDirty(0, 0, true)
	if _, width := cb.Put(0, 0, "", StyleDefault); width != 0 {
		t.Fatalf("empty string width %d, want 0", width)
	}
}